Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18905b0067
|
||
|
|
9db6d4bb59 | ||
|
|
9f581996d0 | ||
|
|
c9d324acd4 | ||
|
|
cd2128f78f | ||
|
|
3d336c3dfe
|
||
|
|
73411423df | ||
|
|
cccd0ac0ab
|
||
|
|
2681447b07 | ||
|
|
55a28c7031
|
||
|
|
2491669f23 | ||
|
|
36f1b939b4
|
||
|
|
77d63135af
|
||
|
|
603e518a34 | ||
|
|
d1eb78c1df
|
||
|
|
2810e00e9c | ||
|
|
89a95737a5
|
||
|
|
86b8dbf13d | ||
|
|
4471a13e78
|
||
|
|
c39d78901c | ||
|
|
1e25fe54d2
|
||
|
|
0fe99db48d | ||
|
|
b08057180b
|
||
|
|
79b85a163d | ||
|
|
4e32aebc00
|
||
|
|
7bafab62cc
|
||
|
|
a1914f7553
|
||
|
|
51a5eaef74 | ||
|
|
47c3aac1b2
|
||
|
|
bf8f40959a | ||
|
|
f7c031b793 | ||
|
|
cd9423e966 | ||
|
|
d97418447b
|
||
|
|
c15872a373
|
||
|
|
fc800b8124 | ||
|
|
30ac598952
|
||
|
|
21b0e61cbb |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -52,7 +52,7 @@ The package layout per phase is in `docs/DESIGN.md`.
|
||||
## Dependencies
|
||||
|
||||
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
|
||||
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
|
||||
|
||||
## Release
|
||||
|
||||
@@ -4,8 +4,30 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- EDI home is business-only: System, Dead letters, Certificates expiring and Setup status move to `EdiHome.ADMIN_WIDGET_NAMES` for the host's Admin home (gated to `edi.admin.hasAccess`, full width); every home widget now requires `edi.hasAccess` and is hidden without it. A new "Inbound documents are delayed" alert links to the delayed inbound documents once the oldest is older than `inboundDelayedAfterMinutes` (default 60). Setup counts that fail show "Unavailable" and are logged instead of -1.
|
||||
|
||||
- UI API extension edi 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained deprecated EdiErrorV1 schema.
|
||||
|
||||
- Derive qbit metadata version from the Maven artifact version, keeping release metadata and help bundle versions in sync.
|
||||
|
||||
- Require QQQ 4.1.0-RC.7 for core help-bundle registration and coverage validation.
|
||||
|
||||
- X12 routing and sending honor identity `isActive`, matching EDIFACT.
|
||||
|
||||
### Added
|
||||
|
||||
- Complete, host-neutral `help/qbit-edi.yaml` bundle registered with QQQ core: app, tables, fields, actions, widgets and possible-value sources, with a coverage gate and HOST overrides. Table/app fallback remains available without `helpContent`.
|
||||
- Setup status section on the EDI home (partner, identities, agreement, agreement documents, transport, certificates, guides, maps) with the next step; it counts only the active rows the runtime uses.
|
||||
- "Install sample maps" action on Maps: standard streamed ETL, insert-only by name, installs the 32 bundled synthetic maps as draft, unbound maps bound to the built-in guide.
|
||||
- `EdiTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help.
|
||||
|
||||
- Phase f1 `edi` 1.0.0 spec'd UI API: guide tree, map target/transform metadata, encrypted base64 sample preview with unsaved rule overrides, and per-rule validation under `/qqq/v1/qbit/edi`.
|
||||
- Versioned `Edi...V1` schemas and generated OpenAPI fragment with the RC.6 contract/compatibility checks; real HTTP strict-mode permission, PHI, upload, isolation and validation tests.
|
||||
- Shared sandbox/record-action preview service, current/original guide selection, caller-session transforms, structured revision/expected diffs and value-free PHI diagnostics.
|
||||
- Transform registry argument schemas, including host-supplied schemas. Preview Map record actions now enforce the documented exact `edi.map.preview` permission through core's full-name permission checker.
|
||||
|
||||
- Phase g EDIFACT syntax 3/4 envelopes (UNA/UNB, optional UNG/UNE, UNH/UNT, UNZ), release escaping, native envelope findings, UNB identity routing, 14-digit UN control allocation and duplicate detection.
|
||||
- Original D96A/D01B ORDERS, ORDRSP, DESADV, INVOIC and CONTRL guides; ORDERS inbound and retail response outbound sample maps with composite fields and segment groups.
|
||||
- Standard-neutral document validation/mapping, mixed X12/EDIFACT inbox framing, CONTRL generation and reconciliation with overdue resolution and rejection alerts, native outbound envelopes and archived resend-as-new.
|
||||
@@ -45,8 +67,20 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
- Repository scaffold to the QRun qbit repository standard: `qbit-build-parent` 2.0.0, QQQ 4.1.0-RC.5, Maven wrapper, Checkstyle, JaCoCo gate, CircleCI qqq-orb 0.6.8, release-candidate workflow, Dependabot, community files.
|
||||
- `EdiQBitProducer` and `EdiQBitConfig` (table prefix `edi_`, permission base `edi`).
|
||||
|
||||
### Changed
|
||||
|
||||
- QQQ baseline is now 4.1.0-RC.6. EDI tables/fields and healthcare test fixtures use core `DataClassification` through `AiTableMetaData`/`AiFieldMetaData`, preserving PHI, PII, CONFIDENTIAL and PHI AI-exposure restrictions.
|
||||
- Exception Assign and new/reopened partner rejection/dead-letter exceptions send value-free core notifications after commit, with capturing-sink and PHI canary coverage.
|
||||
- Retain `PhiLogScope` for core #1147 until RC.7: RC.6 still logs RDBMS query filters and has no redaction hook or `FieldAccessHelper`. Contingent dependency-behavior APIs are also absent; shared-choice filters remain tested.
|
||||
|
||||
### Removed
|
||||
|
||||
- Reflective `EdiClassifications` RC.5 stand-in. File-gateway stand-ins remain until its API is published; the object-storage API integration is unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Shared-choice filters group partner-or-shared alternatives beneath an AND root, so core search criteria cannot admit another partner's transports.
|
||||
|
||||
- 999 now accepts SNIP 3+ findings with `E` instead of rejecting every ERROR. HIPAA acknowledgement selection follows the implementation version and enabled agreement expectation; outbound HIPAA ST carries its implementation identifier.
|
||||
- Qualified HL/NM1 anchors distinguish healthcare hierarchy levels; repeated loop names under different parents retain separate occurrences. Situational rules inherit direct ancestor segments without borrowing values from sibling occurrences.
|
||||
- Review/Result records expose identifiers only. A marked RC.5 PHI logging bridge prevents core upsert query diagnostics from copying claim/member values into logs.
|
||||
|
||||
@@ -52,7 +52,7 @@ The package layout per phase is in `docs/DESIGN.md`.
|
||||
## Dependencies
|
||||
|
||||
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
|
||||
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
|
||||
|
||||
## Release
|
||||
|
||||
@@ -34,7 +34,7 @@ ALTER TABLE edi_agreement ADD COLUMN syntax_identifier VARCHAR(255);
|
||||
|
||||
## Requirements and installation
|
||||
|
||||
Java 21, QQQ **4.1.0-RC.5**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
|
||||
Java 21, QQQ **4.1.0-RC.7+**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
|
||||
Apply [the PostgreSQL schema](docs/ddl/edi-postgres.sql). Targets and their associations must
|
||||
share the EDI backend so each transaction set can commit or roll back atomically.
|
||||
|
||||
@@ -57,9 +57,26 @@ a least-privilege `QSession` with target and association permissions. Applying w
|
||||
produces an APPLY exception: `no service session configured`.
|
||||
|
||||
The `edi` app has Activity/Setup sections, typed home widgets, record screens and standard
|
||||
Review/Result actions. Main tables have RC.5 search-field metadata; `EdiIntegrations.mainTables()`
|
||||
Review/Result actions. Main tables have RC.6 search-field metadata; `EdiIntegrations.mainTables()`
|
||||
also exposes the list for host search registration.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-edi.yaml](src/main/resources/help/qbit-edi.yaml), a `QBIT`
|
||||
bundle versioned with this artifact. It covers the EDI app, tables, fields, actions, widgets,
|
||||
and possible-value sources. Live `{{...}}` references follow the current metadata.
|
||||
|
||||
Hosts can register their own `layer: HOST` bundle with `HelpBundles.register(qInstance,
|
||||
"help/my-host.yaml")`, using the same keys and roles to override selected entries. Enable
|
||||
core's `helpContent` provider with provenance fields, then call
|
||||
`QInstanceHelpContentManager.loadHelpContent(qInstance)` and `HelpBundles.syncOnStartup(qInstance)`
|
||||
after defining metadata. Core preserves user edits during sync. If you configure a different
|
||||
EDI app name, use that name in your host's app help key.
|
||||
|
||||
Registration is safe without `helpContent`; those hosts retain the table/app help fallback.
|
||||
`EdiHelpBundleTest` runs the core `HelpBundleAssertions` coverage gate and checks widget and
|
||||
possible-value-source coverage during `./mvnw verify`, without host-specific exclusions.
|
||||
|
||||
## Tables
|
||||
|
||||
There are **26 persisted tables**, whose SQL names equal their QQQ names.
|
||||
@@ -73,9 +90,60 @@ There are **26 persisted tables**, whose SQL names equal their QQQ names.
|
||||
| Storage/queue | `edi_payload`, `edi_outbound_request`, `edi_outbound_message` |
|
||||
|
||||
`edi_preview_download` is a **storage-only metadata alias**, not a SQL table. Supporting tables
|
||||
appear as child lists. Partner-scoped form choices use RC.5 contingent filters, including shared
|
||||
appear as child lists. Partner-scoped form choices use RC.6 contingent filters, including shared
|
||||
transports/guides and local certificates/identities.
|
||||
|
||||
## UI API
|
||||
|
||||
With optional Javalin present, the producer registers the `edi` extension, version `1.1.0`,
|
||||
under `/qqq/v1/qbit/edi`, requiring QQQ 4.1.0-RC.15 (UI API 1.6.0). All routes are endpoint specs and pass strict UI-route auditing.
|
||||
|
||||
| Method | Path after `/qqq/v1/qbit/edi` | Result |
|
||||
|---|---|---|
|
||||
| GET | `/guides/{guideId}/tree` | Nested loops, ordered segments, elements and up to 200 active codes per element |
|
||||
| GET | `/maps/{mapId}/target` | Target fields, direct associations, current rules and registered transform argument schemas |
|
||||
| POST | `/sandbox/preview` | Parse counts, positional findings, acknowledgement, mapped records and revision/expected diffs |
|
||||
| POST | `/maps/{mapId}/validate` | Per-rule path, transform and required-argument errors |
|
||||
|
||||
Preview accepts `{mapId, sampleId?, sampleBase64?, sampleName?, ruleOverrides?, guideRevision?}`.
|
||||
Supply exactly one sample source. Uploads are base64 JSON, at most **5 MiB decoded**, archived as
|
||||
encrypted `edi_map_sample` records through the same storage as Preview Map. No runtime files are
|
||||
created. Configure the host's Javalin `http.maxRequestSize` to at least 8 MiB to allow base64
|
||||
overhead and request metadata. Oversized decoded samples return 400; a smaller host limit returns 413.
|
||||
|
||||
`ruleOverrides` replaces the entire rule list in memory, including an empty list; it never saves
|
||||
rules. `CURRENT` (default) selects the newest active revision of the pinned guide's name, standard,
|
||||
version, transaction set and partner; `ORIGINAL` uses the map's pinned guide. Validate accepts
|
||||
`{rules: [...]}` and checks the pinned guide. Rule fields and `transformArgs` have the same shape
|
||||
as core table CRUD (`transformArgs` is a JSON object encoded as a string).
|
||||
|
||||
Every read enforces table permissions and partner locks with the caller's session, regardless of
|
||||
the host's default core-enforcement setting. Preview additionally requires **`edi.map.preview`**,
|
||||
shared with both Preview Map record actions. Reading target metadata requires target/association
|
||||
table read permissions; guide definitions, code lists, map rules and stored samples require their
|
||||
own table reads. LOOKUP and host transforms run as the caller, without service-account elevation.
|
||||
Only the internal PHI classification check and encrypted sample storage use the EDI system writer.
|
||||
|
||||
For PHI, `result` and `ack.text` are null and both diff lists are empty; findings retain positions
|
||||
and codes with generic messages. Mapping failures are value-free `MAP` findings. Preview never
|
||||
writes target records. Diffs contain differing document records as `{documentIndex, before, after}`;
|
||||
acknowledgements for multiple groups are concatenated, with `type: MIXED` for different ack types.
|
||||
|
||||
The mapper saves `edi_map_rule` through the **core table API**, using its normal
|
||||
`edi.map_rule.insert|edit|delete` permissions and record locks. There are no custom rule-write routes.
|
||||
The Next mapper page is separate phase-f work.
|
||||
|
||||
The generated contract is `src/main/resources/openapi/qqq-v1/edi.yaml`; extension component names are
|
||||
`Edi...V1`, alongside core `BasicErrorResponseV1`. `EdiErrorV1` remains deprecated for compatibility. Regenerate with the locally installed migration build:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -o -B '-Dqqq.snapshot.version=4.1.99-SNAPSHOT' '-Dtest=EdiApiContractTest' '-Dedi.openapi.update=true' test
|
||||
```
|
||||
|
||||
Core provides `UiApiContract.check`, used to compare generated and checked-in YAML, validate
|
||||
operation IDs and references, and run `OpenApiCompatibilityChecker` against the committed fragment baseline. The extension also appears
|
||||
in the host's `/qqq/v1/openapi.yaml` and `/qqq/v1/version`.
|
||||
|
||||
## Inbound operation
|
||||
|
||||
`InboundSink.receive(InboundPayload)` stores encrypted bytes and SHA-256 before returning a durable
|
||||
@@ -111,11 +179,35 @@ Outbound maps support nested association paths; inbound target paths retain the
|
||||
Preview shows parse counts, SNIP findings, the would-be ack, mapped JSON, expected-result and
|
||||
previous-revision comparisons. It writes no target records. PHI previews suppress document values.
|
||||
A session-authorized encrypted JSON report is downloadable. Select a stored sample or upload
|
||||
synthetic EDI on the standard Review step. Browser uploads require the host's RC.5 Javalin
|
||||
synthetic EDI on the standard Review step. Browser uploads require the host's RC.6 Javalin
|
||||
`uploadedFileArchiveTableName` to be `edi_preview_download` and multipart handling without disk
|
||||
staging. The alias encrypts streams and persists samples; this process accepts no filesystem archive.
|
||||
Samples must contain synthetic data only.
|
||||
|
||||
## Setup and starter data
|
||||
|
||||
No Liquibase changelog or host include is needed: the qbit seeds its own reference data. When the
|
||||
host calls `EdiRuntime.initialize()` at startup (after the schema exists) the 41 built-in guides
|
||||
load, with their segments, elements, rules, code lists and code values. It only changes a guide when
|
||||
the bundled content changes, so edits to your own rows are kept.
|
||||
|
||||
Everything else is host or runtime data, and every table explains itself when empty (help bundle
|
||||
`help/qbit-edi.yaml`). Set up in this order:
|
||||
|
||||
1. Partner.
|
||||
2. Identities: one of yours (no partner) and one per partner.
|
||||
3. Agreement: pairs your identity with the partner's, for a standard and test or production.
|
||||
4. Agreement documents: one per transaction set and direction, with the guide and optionally a map.
|
||||
5. Transport (AS2, SFTP, HTTPS), linked on the agreement. Certificates only for AS2 signing and encryption.
|
||||
|
||||
The **Setup status** widget (`edi_home_setup`, for the host's Admin home) shows each step, whether
|
||||
it is done (counting only the active rows the runtime uses) and the next step. A count that fails
|
||||
shows "Unavailable" and is logged.
|
||||
|
||||
Maps start empty. The **Install sample maps** action on Maps adds the 32 bundled synthetic examples
|
||||
as draft maps with no partner and no agreement document, by name and never over an existing map. Point
|
||||
a map at your own table, preview it, activate it, then select it on an agreement document.
|
||||
|
||||
## Permissions
|
||||
|
||||
Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*`,
|
||||
@@ -138,7 +230,14 @@ Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*
|
||||
| Scheduled workers / ESB enqueue | `edi.admin` |
|
||||
|
||||
Partner locks use `edi_partner` with `edi.admin` all-access. Home queries explicitly enforce
|
||||
the viewing user's permissions. Processes are table/record actions, never navigation entries.
|
||||
the viewing user's permissions.
|
||||
|
||||
The EDI home shows business work only. Its widgets (`EdiHome.HOME_WIDGET_NAMES`) need
|
||||
`edi.hasAccess` and are hidden without it. "Inbound documents delayed" appears only when the oldest
|
||||
unprocessed inbound document is older than `inboundDelayedAfterMinutes` (default 60), with a link to
|
||||
those documents. System widgets (`EdiHome.ADMIN_WIDGET_NAMES`: System, Dead letters, Certificates
|
||||
expiring, Setup status) are not on the EDI home; they need `edi.admin.hasAccess`, are hidden without
|
||||
it, and a host places them on its Admin home. Processes are table/record actions, never navigation entries.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -165,29 +264,35 @@ in memory. Database storage remains the default for demos/tests.
|
||||
|
||||
For object storage, add/install optional `qbit-object-storage:0.1.0-RC.2`, configure its bucket
|
||||
alias and select `OBJECT_STORAGE`. EDI uses only its public API and system access, with keys
|
||||
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.5.
|
||||
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.6.
|
||||
|
||||
Optional `qqq-esb` publishes setup/activity events after commit. Document events contain only ID
|
||||
and status because RC.5's standard factory otherwise copies PHI fields. Alert names are
|
||||
and status because RC.6's standard factory otherwise copies PHI fields. Alert names are
|
||||
`edi.inbox.dead-letter`, `edi.ack.overdue`, `edi.ack.rejected`, `edi.send.failed`,
|
||||
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.5 requires dotted names).
|
||||
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.6 requires dotted names).
|
||||
|
||||
RC.5 lacks the planned core `DataClassification` API. `EdiClassifications` marks PHI/PII/confidential
|
||||
fields and PHI fields deny AI exposure. Hosts must supply secured-data protection, database
|
||||
encryption at rest and access policies for PHI columns; markers alone do not mask fields.
|
||||
Core `DataClassification` metadata marks PHI/PII/CONFIDENTIAL fields through `AiFieldMetaData`,
|
||||
with a CONFIDENTIAL table default through `AiTableMetaData`. PHI fields deny AI exposure.
|
||||
Hosts must supply secured-data protection, database encryption at rest and access policies for PHI columns; markers alone do not mask fields.
|
||||
The home reports installed integration and storage state.
|
||||
|
||||
Assign sends a value-free `QAlerts.notify` notification to the assignee after commit. Newly opened
|
||||
or reopened REJECTED_ACK/DEAD_LETTER exceptions notify distinct assignees of OPEN/IN_PROGRESS
|
||||
exceptions for the same partner; these existing assignments define the partner's recipients.
|
||||
Repeated occurrences do not send another notification. Titles and bodies contain no document,
|
||||
partner or exception-summary values. Without an alerts integration, core uses its logging sink.
|
||||
|
||||
## Outbound and transports
|
||||
|
||||
Active OUTBOUND agreement documents select the source table, map, guide and trigger. RECORD_CHANGE
|
||||
uses core's instance listener and the writer's transaction. Source and EDI tables must share the
|
||||
backend. `triggerFilter` is a serialized QQQ `QQueryFilter`; MANUAL also honors that filter.
|
||||
RC.5 catches listener errors without failing the source write: successful enqueues roll back with
|
||||
RC.6 catches listener errors without failing the source write: successful enqueues roll back with
|
||||
the source transaction, but hosts must monitor failed listener execution.
|
||||
|
||||
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.5 `EsbTrigger` to invoke it with
|
||||
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.6 `EsbTrigger` to invoke it with
|
||||
`sourceTableName`, `sourceRecordId` and `eventId`; broker setup belongs to the host. Event IDs are
|
||||
unique per agreement document. RC.5's consumer runner uses JMS, so broker-free acceptance calls
|
||||
unique per agreement document. RC.6's consumer runner uses JMS, so broker-free acceptance calls
|
||||
this process directly.
|
||||
|
||||
`edi_outbound_worker` and `edi_send_worker` run every 15 seconds when a scheduler is configured.
|
||||
@@ -299,17 +404,18 @@ host tables live **only in test sources/resources** and use seeded synthetic pat
|
||||
Their NPI `1234567893` is a checksum test fixture, not an asserted provider identity.
|
||||
|
||||
**The host must encrypt the database at rest and should install qbit-secured-data.** Payloads
|
||||
are always AES-GCM encrypted; PHI columns rely on host database encryption. RC.5's
|
||||
`EdiClassifications` stand-in marks PHI/PII/CONFIDENTIAL and denies PHI AI exposure, but does not
|
||||
mask values. Without secured-data policies, a user with table access can read PHI columns.
|
||||
are always AES-GCM encrypted; PHI columns rely on host database encryption. RC.6 core
|
||||
classification metadata marks PHI/PII/CONFIDENTIAL and denies PHI AI exposure, but does not
|
||||
provide secured-data field tiers. Without secured-data policies, a user with table access can
|
||||
read PHI columns.
|
||||
The home explicitly reports this limitation. Use record-level audits on host PHI target tables
|
||||
until core classified-value audit protection is available.
|
||||
|
||||
RC.5 also logs query criteria verbatim. The marked `PhiLogScope` bridge suppresses synchronous
|
||||
logger diagnostics during PHI mapping/application and preview; ordinary value-free EDI events
|
||||
remain persisted. It changes no logger levels and does not suppress other threads. Replace both
|
||||
bridges with core secured-data support at 4.1.0-RC.6. Map samples use record-level audits to avoid
|
||||
copying expected-result PHI into audit details. Host code outside these scopes must protect its
|
||||
RC.6 still includes query criteria in RDBMS failure logs and has no logging redaction hook.
|
||||
The marked `PhiLogScope` bridge suppresses synchronous logger diagnostics during PHI mapping/application and preview; ordinary value-free EDI events
|
||||
remain persisted. It changes no logger levels and does not suppress other threads. The bridge
|
||||
tracks core #1147 (secured-data logging), with a removal/review deadline of 4.1.0-RC.7.
|
||||
Map samples use record-level audits to avoid copying expected-result PHI into audit details. Host code outside these scopes must protect its
|
||||
own logging, audits and asynchronous work.
|
||||
|
||||
Download Raw Document uses a session-bound process grant, rechecks `edi.raw.download` and record
|
||||
@@ -317,10 +423,10 @@ read/partner access at download time, streams decrypted bytes without local file
|
||||
`RAW_DOWNLOADED` with the user and row references, never content. Download filenames are generic.
|
||||
|
||||
`PhiCanaryTest` scans every five-character marker window through gateway/HTTPS intake, forced
|
||||
SNIP 2/3/4 errors, mapping/apply, 999, replay/resend, Review/Result, alerts, DEBUG logs, all EDI
|
||||
SNIP 2/3/4 errors, mapping/apply, 999, replay/resend, Review/Result, alerts/notifications, DEBUG logs, all EDI
|
||||
query exports and all home/child widgets. FULL exports exempt only PHI-classified columns;
|
||||
RC.5 HIDDEN field-lock exports have no exemptions. MASKED tiers and the optional Quick Search
|
||||
qbit require the newer secured-data integration and are not claimed by these RC.5 tests.
|
||||
RC.6 HIDDEN field-lock exports have no exemptions. MASKED tiers and the optional Quick Search
|
||||
qbit require the newer secured-data integration and are not claimed by these RC.6 tests.
|
||||
|
||||
## Build and verification
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Upgrade notes
|
||||
|
||||
## Unreleased
|
||||
|
||||
- No schema change and no Liquibase include.
|
||||
- Behavior change: an inactive identity (`isActive` false) now stops X12 routing and sending, as it already did for EDIFACT. Inbound documents from or to it are rejected as an unknown partner, and outbound requests for its agreements wait until it is active. Identities default to active, so only identities someone switched off are affected.
|
||||
- Hosts that already call `EdiRuntime.initialize()` at startup keep getting the 41 built-in guides and code lists. A host that never called it will now see "Needs setup" for Guides and code lists on the home.
|
||||
- New widget "Setup status" (`edi_home_setup`).
|
||||
- Behavior change: the EDI home shows business widgets only (`EdiHome.HOME_WIDGET_NAMES`), each now requiring `edi.hasAccess` (hidden without it). `edi_home_system`, `edi_home_dead`, `edi_home_certificates` and `edi_home_setup` left the EDI home: they are listed in `EdiHome.ADMIN_WIDGET_NAMES`, require `edi.admin.hasAccess` (hidden without it), and the host places them on its Admin home.
|
||||
- New business widget `edi_home_delayed` ("Inbound documents are delayed"), shown only when the oldest unprocessed inbound document is older than the new config value `inboundDelayedAfterMinutes` (default 60).
|
||||
- New table action "Install sample maps" on Maps, permission `edi.admin`. It is never run automatically and only adds draft maps whose name does not exist yet.
|
||||
+54
-23
@@ -130,7 +130,7 @@ Package layout (`com.kingsrook.qbits.edi`):
|
||||
|
||||
| Dependency | Scope | Why |
|
||||
|---|---|---|
|
||||
| QQQ `4.1.0-RC.5` (then each new RC, G7) | compile | core, `QAlerts.raise/resolve` (RC.5) |
|
||||
| QQQ `4.1.0-RC.6` (then each new RC, G7) | compile | core classification, `QAlerts.raise/resolve/notify` |
|
||||
| `qqq-middleware-javalin` | optional | hooks route provider; UI API extension (RC.6) |
|
||||
| `qqq-esb` | optional | outbound trigger from ESB table events |
|
||||
| `qqq-backend-module-filesystem` (SFTP) | compile | SFTP push and VAN polling. This is core's SFTP client, the one qbit-sftp-data-integration uses; EDI uses it directly instead of depending on that qbit (G1). Remote SFTP only; nothing local. |
|
||||
@@ -139,13 +139,20 @@ Package layout (`com.kingsrook.qbits.edi`):
|
||||
| `qbit-object-storage` (its `api` package only) | optional | raw payload storage (section 10) |
|
||||
| `qbit-file-gateway` (its `api` package only) | optional | inbound SFTP and partner-pull OUTBOX (section 5.3) |
|
||||
|
||||
Core features that arrive in RC.6, and how EDI bridges until then:
|
||||
Core features checked against the `v4.1.0-RC.6` tag:
|
||||
|
||||
| Need | Core | Until RC.6 |
|
||||
| Need | Core | RC.6 status |
|
||||
|---|---|---|
|
||||
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | `EdiClassifications` stand-in, `STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6)`, sets the classification reflectively when the class is present |
|
||||
| User notifications | `QAlerts.notify` (#1125) | alerts only via `QAlerts.raise` (RC.5); notifications added with the RC.6 bump |
|
||||
| UI API extension point | `MiddlewareSpecExtension` (#1128) | the sandbox is a record action (section 8.3); spec'd endpoints come in phase f |
|
||||
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | Uses `AiFieldMetaData.withClassification` and CONFIDENTIAL `AiTableMetaData` defaults; classification stand-in removed, including healthcare test fixtures |
|
||||
| User notifications | `QAlerts.notify` (#1125) | Assign notifies the assignee; new/reopened rejection and dead-letter exceptions notify current partner assignees (section 4.4) |
|
||||
| UI API extension point | `MiddlewareSpecExtension` (#1128) | Available in core; sandbox remains a record action until phase f |
|
||||
| Contingent choice dependency behavior | `PossibleValueDependencyBehavior`, `possibleValueSourceDependsOn` | Neither API exists in the tag; existing filters retain shared choices before a partner is selected |
|
||||
| Secured-data tiers and log redaction | `FieldAccessHelper`, logging hook (#1147) | Spec only; no implementation in the tag. Retain `PhiLogScope`, `STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7)` |
|
||||
|
||||
**RC.6 baseline:** classifications and notifications now use core APIs. The PHI logging bridge
|
||||
remains because RDBMS query failures still log filters and QLogger has no classified-value
|
||||
redaction hook; review/remove it by RC.7 under #1147. FULL and HIDDEN field-lock canaries run;
|
||||
MASKED tiers await `FieldAccessHelper`. Host logging/audits outside PHI scopes still need protection.
|
||||
|
||||
The file-gateway `api` ships in that qbit's phase (a). Until it is on a published SNAPSHOT, EDI uses
|
||||
`GatewayStandIn` with the identical shape, marked `STAND-IN(core=QRun-IO/qbit-file-gateway,
|
||||
@@ -247,7 +254,11 @@ One row per document, version and direction that the agreement carries.
|
||||
- When the ack arrives, `QAlerts.resolve("edi.ack.overdue", dedupKey, note)`.
|
||||
- A rejecting ack (AK9/IK9 `R`, or partial `P`) raises `edi.ack.rejected` (ERROR) and opens a
|
||||
`REJECTED_ACK` exception.
|
||||
- From RC.6, assignees of exceptions also get a `QAlerts.notify` notification.
|
||||
- Assign sends the assignee a value-free `QAlerts.notify` notification after the update commits.
|
||||
New or reopened `REJECTED_ACK`/`DEAD_LETTER` exceptions notify distinct assignees of
|
||||
OPEN/IN_PROGRESS exceptions for the same partner (including the reopened row's assignee).
|
||||
Existing active exception assignments define partner recipients; no new assignment model is
|
||||
introduced. Coalesced repeat occurrences do not notify again. Rollbacks discard notifications.
|
||||
|
||||
## 5. Transports
|
||||
|
||||
@@ -565,16 +576,41 @@ upload is stored as a sample), and the standard Review/Result lines show:
|
||||
5. the diff against the previous map revision's result and against `expectedResultJson`.
|
||||
|
||||
Nothing is written to target tables: the preview runs the whole pipeline with a no-op applier.
|
||||
Phase f adds spec'd endpoints (`POST /qqq/v1/qbit/edi/sandbox/preview` and related) for the mapper
|
||||
UI.
|
||||
Phase f1 provides the `edi` 1.0.0 `MiddlewareSpecExtension`, with component schemas named
|
||||
`Edi...V1` and a generated, contract-tested fragment at `openapi/qqq-v1/edi.yaml`:
|
||||
|
||||
### 8.4 No-code mapper UI (phase f; specified now, built later)
|
||||
- `GET /qqq/v1/qbit/edi/guides/{guideId}/tree`
|
||||
- `GET /qqq/v1/qbit/edi/maps/{mapId}/target`
|
||||
- `POST /qqq/v1/qbit/edi/sandbox/preview`
|
||||
- `POST /qqq/v1/qbit/edi/maps/{mapId}/validate`
|
||||
|
||||
Preview Map and the HTTP preview share `MapPreviewService`. HTTP preview takes `mapId`, exactly
|
||||
one of `sampleId`/`sampleBase64`, optional `sampleName`, a complete in-memory `ruleOverrides`
|
||||
replacement, and `guideRevision`. Uploads are limited to 5 MiB decoded and stored encrypted as
|
||||
samples; the host must allow at least 8 MiB HTTP bodies for base64 overhead. `CURRENT` (HTTP default)
|
||||
selects the newest active revision in the pinned guide family and partner; `ORIGINAL` uses the
|
||||
map's pinned guide, as the record action does.
|
||||
|
||||
The response contains parse counts, positional findings, acknowledgement type/text, mapped records
|
||||
and associations, structured per-document diffs, and `phiSuppressed`. PHI returns null result/ack
|
||||
text, empty diffs and generic finding messages. Mapping errors are value-free `MAP` findings.
|
||||
The preview action permission is exactly `edi.map.preview`, with no `.hasAccess` suffix.
|
||||
Guide, sample and rule reads explicitly enforce permissions and record locks; transforms run in
|
||||
the caller session. Only classification and encrypted sample storage use the internal system writer.
|
||||
|
||||
### 8.4 No-code mapper UI (phase f1 API built; Next page follows)
|
||||
A custom Next UI page (custom UIs may be nav entries):
|
||||
- left, the guide tree (loops, segments, elements with usage and code lists); right, the target
|
||||
table's fields and associations; drag to create a rule, then choose a transform;
|
||||
- a live preview pane on a chosen sample (the sandbox endpoint), with errors inline;
|
||||
- revisions: edit a DRAFT, activate, compare revisions;
|
||||
- reads and writes only through `/qqq/v1` (CRUD on `edi_map_rule` plus the extension endpoints).
|
||||
- reads and writes only through `/qqq/v1`: the guide-tree and map-target GETs above, the sandbox
|
||||
preview POST and the map validate POST; writes remain core CRUD on `edi_map_rule`, enforcing
|
||||
`edi.map_rule.insert|edit|delete` and record locks.
|
||||
- target metadata includes direct associations and transform argument JSON Schemas from the
|
||||
registry. Validate accepts `{rules: [...]}`, returns indexed per-rule errors against the pinned
|
||||
guide/target and never executes transforms or saves rules. Guide code lists return at most
|
||||
200 active codes per element.
|
||||
|
||||
## 9. Flows
|
||||
|
||||
@@ -770,7 +806,7 @@ so.
|
||||
|
||||
## 12. HIPAA and PHI
|
||||
|
||||
- **Classification** (`DataClassification`, core #1142; the stand-in until RC.6):
|
||||
- **Classification** (`DataClassification`, core #1142; native RC.6 metadata):
|
||||
- PHI: `edi_transaction.phiBusinessKey`, `edi_transaction_error.valueExcerpt` (never written for
|
||||
PHI documents, but classified anyway), `edi_map_sample.expectedResultJson`, `edi_inbox.fileName`
|
||||
(file names often carry member or claim ids), and the payloads themselves (encrypted, never in a
|
||||
@@ -794,10 +830,10 @@ so.
|
||||
- **Canary egress tests** (phase e, the core SECURED-DATA procedure): two randomized markers per
|
||||
patient-name, member-id, claim-number and diagnosis field run through gateway and HTTPS hooks,
|
||||
validation with SNIP 2/3/4 failures, 999, map/apply, exceptions, reprocess/resend, every home and
|
||||
child widget, every persisted `edi_` table's QueryAction export, Review/Result, captured alerts
|
||||
child widget, every persisted `edi_` table's QueryAction export, Review/Result, captured alerts and notifications
|
||||
and an in-memory DEBUG log appender. Every five-character window is scanned, detecting all
|
||||
longer substrings. Only explicit PHI value/display-value columns are exempt for FULL exports;
|
||||
ciphertext bytes are checked for plaintext. RC.5 HIDDEN field locks are separately exercised
|
||||
ciphertext bytes are checked for plaintext. RC.6 HIDDEN field locks are separately exercised
|
||||
without exemptions. The storage-only download alias has no query surface. MASKED tiers and
|
||||
the optional Quick Search index await their host/core integration; they are not simulated.
|
||||
Long diagnosis markers use a widened test-only guide element so accepted-with-error fixtures
|
||||
@@ -942,7 +978,6 @@ queued acknowledgements are implemented. These RC.5 adaptations refine the execu
|
||||
Object storage uses only its published API.
|
||||
- Standard optional ESB publication follows RC.5. Document events use the core envelope/publisher
|
||||
with ID/status only because the standard factory copies every field, including PHI keys.
|
||||
- The a1 classification stand-in and host responsibility for PHI field protection remain unchanged.
|
||||
|
||||
Synthetic H2 verification covers permissions, association rollback, retry/stale claims, gateway
|
||||
storage failures, original/current replay, 997/999, ack reconciliation, previews and home renderers.
|
||||
@@ -998,13 +1033,9 @@ RC.5 adaptations implemented in this worktree:
|
||||
|
||||
## Phase e implementation notes (2026-10-14)
|
||||
|
||||
- Core RC.5 logs full query criteria, including PHI upsert keys. `PhiLogScope` is a scoped,
|
||||
marked bridge to suppress synchronous diagnostic logging during PHI map/apply and previews;
|
||||
it leaves unrelated threads and persisted value-free events intact. Remove with the core
|
||||
classification/logging bridge at RC.6. Map samples use record-level audits on RC.5 because their
|
||||
expected-result column can contain PHI. Host asynchronous code, target audits and queries outside
|
||||
these boundaries still need host secured-data protection. Raw downloads use RC.5's existing
|
||||
session-bound storage grant and an `edi_event` audit rather than a not-yet-available access log.
|
||||
- See the RC.6 baseline in section 3. Map samples use record-level audits because expected-result
|
||||
JSON can contain PHI. Raw downloads use a session-bound storage grant and an `edi_event` audit;
|
||||
a secured-data access log remains host integration work.
|
||||
- Situational rules bind to their occurrence and inherit only direct ancestor segments, so a
|
||||
sibling line's service date cannot satisfy another line. Fixed loop-head qualifiers distinguish
|
||||
HL/NM1 roles; the same 2300/2400 names can occur under subscriber and patient parents.
|
||||
@@ -1018,7 +1049,7 @@ RC.5 adaptations implemented in this worktree:
|
||||
associations. These are baseline examples, not a complete licensed TR3 implementation.
|
||||
- Child-list widgets explicitly enforce their nested queries on RC.5; the generic renderer
|
||||
otherwise bypasses field locks inside the already-authorized widget action scope.
|
||||
- MASKED-user response scanning and Quick Search indexing are not available on the pinned RC.5
|
||||
- MASKED-user response scanning and Quick Search indexing are not available on the pinned RC.6
|
||||
test classpath. FULL and actual HIDDEN-field-lock scans run now; no simulated masking is used.
|
||||
|
||||
## Phase g implementation notes (2026-10-14)
|
||||
|
||||
@@ -126,7 +126,36 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-edi.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-edi.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -213,7 +242,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -89,6 +89,7 @@ public class EdiQBitConfig implements QBitConfig
|
||||
private String appLabel = "EDI";
|
||||
private String parentAppName;
|
||||
private String publicBaseUrl;
|
||||
private Integer inboundDelayedAfterMinutes = 60;
|
||||
|
||||
|
||||
|
||||
@@ -114,6 +115,10 @@ public class EdiQBitConfig implements QBitConfig
|
||||
{
|
||||
errors.add("secretsResolver and partnerLocks are required");
|
||||
}
|
||||
if(inboundDelayedAfterMinutes == null || inboundDelayedAfterMinutes < 1)
|
||||
{
|
||||
errors.add("inboundDelayedAfterMinutes must be at least 1");
|
||||
}
|
||||
if(appName == null || appName.isBlank())
|
||||
{
|
||||
errors.add("appName is required");
|
||||
@@ -390,4 +395,22 @@ public class EdiQBitConfig implements QBitConfig
|
||||
publicBaseUrl = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How old (in minutes) the oldest unprocessed inbound document may get before
|
||||
** the EDI home says "Inbound documents are delayed". Default 60.
|
||||
*******************************************************************************/
|
||||
public Integer getInboundDelayedAfterMinutes()
|
||||
{
|
||||
return (inboundDelayedAfterMinutes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the inbound delay threshold in minutes.
|
||||
*******************************************************************************/
|
||||
public EdiQBitConfig withInboundDelayedAfterMinutes(Integer value)
|
||||
{
|
||||
inboundDelayedAfterMinutes = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,13 @@
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -36,13 +40,41 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-edi";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private EdiQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = EdiQBitProducer.class.getClassLoader().getResourceAsStream("qbit-edi.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw new IllegalStateException("Missing qbit-edi.properties; build with Maven to supply the artifact version");
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw new IllegalStateException("Missing or unfiltered version in qbit-edi.properties; build with Maven");
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not read the artifact version from qbit-edi.properties", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent configuration setter.
|
||||
*******************************************************************************/
|
||||
@@ -130,6 +162,16 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(instance);
|
||||
output.addSelfToInstance(instance);
|
||||
EdiRuntime.install(instance, qBitConfig);
|
||||
// Registration is metadata-only; core startup sync skips hosts without helpContent.
|
||||
HelpBundles.register(instance, "help/qbit-edi.yaml");
|
||||
// The app name is configurable; its bundle key must follow the installed app.
|
||||
HelpBundles.of(instance).getBundles().getLast().getEntries().stream()
|
||||
.filter(entry -> "app:edi;slot:help".equals(entry.getKey()))
|
||||
.forEach(entry -> entry.setKey("app:" + qBitConfig.getAppName() + ";slot:help"));
|
||||
if(instance.getTable("helpContent") == null)
|
||||
{
|
||||
com.kingsrook.qbits.edi.metadata.EdiHelp.apply(instance, qBitConfig.getAppName());
|
||||
}
|
||||
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.metadata.EdiRecordLabels());
|
||||
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.flow.OutboundEnqueue.class));
|
||||
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData()
|
||||
@@ -138,6 +180,7 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
.withPlugin(new com.kingsrook.qbits.edi.as2.As2Transport()).withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("EMAIL")));
|
||||
try
|
||||
{
|
||||
com.kingsrook.qbits.edi.api.EdiApi.register(instance);
|
||||
com.kingsrook.qqq.middleware.javalin.QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(
|
||||
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.transport.HttpsRouteProvider.class))
|
||||
.withAdditionalRouteProviderReference(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.as2.As2RouteProvider.class));
|
||||
|
||||
@@ -64,6 +64,7 @@ public final class EdiRuntime
|
||||
private final Ack999Builder ack999 = new Ack999Builder();
|
||||
private final TA1Builder ta1 = new TA1Builder();
|
||||
private final InboundAckReader acknowledgements = new InboundAckReader();
|
||||
private final com.kingsrook.qbits.edi.flow.OutboundSourceTables outboundSources = new com.kingsrook.qbits.edi.flow.OutboundSourceTables();
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct services without resolving keys until content is stored or opened.
|
||||
@@ -95,6 +96,22 @@ public final class EdiRuntime
|
||||
return (Objects.requireNonNull(INSTANCES.get(QContext.getQInstance()), "EDI runtime has not been installed"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services for the current QQQ context, or null when none are installed.
|
||||
*******************************************************************************/
|
||||
public static EdiRuntime find()
|
||||
{
|
||||
return (QContext.getQInstance() == null ? null : INSTANCES.get(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached source tables that have active RECORD_CHANGE outbound definitions.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qbits.edi.flow.OutboundSourceTables outboundSources()
|
||||
{
|
||||
return (outboundSources);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Call at host startup after DDL, with the service session established.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionAction;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.fasterxml.jackson.databind.type.LogicalType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI 1.1.0 UI API: four spec'd operations in the default qbit namespace.
|
||||
*******************************************************************************/
|
||||
public final class EdiApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static registration only.
|
||||
*******************************************************************************/
|
||||
private EdiApi()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install with optional Javalin from the qbit producer.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared descriptor for route binding and the committed contract.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("edi").withSemanticVersion("1.1.0")
|
||||
.withDescription("EDI sandbox and mapper, using the signed-in user's permissions.")
|
||||
.withSpec(GuideTreeSpec.class).withSpec(MapTargetSpec.class).withSpec(PreviewSpec.class).withSpec(ValidateSpec.class));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Common typed JSON contract; core owns authentication and error mapping.
|
||||
*******************************************************************************/
|
||||
public abstract static class EdiSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
private final String path;
|
||||
private final String response;
|
||||
private final String request;
|
||||
private final String summary;
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable operation metadata.
|
||||
*******************************************************************************/
|
||||
protected EdiSpec(String path, String response, String request, String summary)
|
||||
{
|
||||
this.path = path;
|
||||
this.response = response;
|
||||
this.request = request;
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish the method, path and permission expectations.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withPath("/qbit/edi" + path).withHttpMethod(request == null ? HttpMethod.GET : HttpMethod.POST)
|
||||
.withShortSummary(summary).withLongDescription(summary + ". Uses the caller session and table read permissions. Preview also requires edi.map.preview. Rule writes use core table CRUD."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare positive integer record identifiers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
String name = path.contains("{guideId}") ? "guideId" : path.contains("{mapId}") ? "mapId" : null;
|
||||
return (name == null ? List.of() : List.of(new Parameter().withName(name).withIn(In.PATH).withRequired(true)
|
||||
.withDescription("Positive record identifier.").withSchema(new Schema().withType(Type.INTEGER))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All schemas use the Edi prefix and V1 suffix.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (path.startsWith("/guides/") ? EdiSchemas.all() : Map.of());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Response component name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return (response);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional request body contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (request == null ? null : EdiSchemas.ref(request));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core middleware errors are explicitly documented.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request or sample exceeds 5 MiB", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible record", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONTENT_TOO_LARGE, "Host HTTP request size limit exceeded", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject malformed or overflowing identifiers without echoing request content.
|
||||
*******************************************************************************/
|
||||
protected Integer id(JsonInput input, String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
int value = Integer.parseInt(input.getPathParam(name));
|
||||
if(value < 1)
|
||||
{
|
||||
throw (new NumberFormatException());
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException("A positive integer identifier is required"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict scalar typing prevents coercion of unsaved edits and sample identifiers.
|
||||
*******************************************************************************/
|
||||
protected <T> T body(JsonInput input, Class<T> type) throws QBadRequestException
|
||||
{
|
||||
ObjectMapper mapper = JsonMapper.builder().disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT).build();
|
||||
mapper.coercionConfigFor(LogicalType.Textual).setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
|
||||
.setCoercion(CoercionInputShape.Float, CoercionAction.Fail).setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail);
|
||||
try
|
||||
{
|
||||
if(input.getBody() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Object body required"));
|
||||
}
|
||||
return (mapper.convertValue(input.getBody(), type));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid body; check property names and types"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the guide tree.
|
||||
*******************************************************************************/
|
||||
public static class GuideTreeSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public GuideTreeSpec()
|
||||
{
|
||||
super("/guides/{guideId}/tree", "EdiGuideTreeV1", null, "Read the guide tree");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().tree(id(input, "guideId")));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read target metadata and map rules.
|
||||
*******************************************************************************/
|
||||
public static class MapTargetSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public MapTargetSpec()
|
||||
{
|
||||
super("/maps/{mapId}/target", "EdiTargetV1", null, "Read target metadata and map rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().target(id(input, "mapId")));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview an encrypted sample with optional unsaved rules.
|
||||
*******************************************************************************/
|
||||
public static class PreviewSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public PreviewSpec()
|
||||
{
|
||||
super("/sandbox/preview", "EdiPreviewV1", "EdiPreviewRequestV1", "Preview an encrypted sample with optional unsaved rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().preview(body(input, EdiSchemas.EdiPreviewRequestV1.class)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate unsaved map rules.
|
||||
*******************************************************************************/
|
||||
public static class ValidateSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public ValidateSpec()
|
||||
{
|
||||
super("/maps/{mapId}/validate", "EdiValidationV1", "EdiValidateRequestV1", "Validate unsaved map rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().validate(id(input, "mapId"), body(input, EdiSchemas.EdiValidateRequestV1.class)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.api;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiAssociationV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiElementV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiFieldV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiGuideTreeV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiLoopV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiPreviewRequestV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiPreviewV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiRuleErrorsV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiRuleV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiSegmentV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiTargetV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiTransformV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiValidateRequestV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiValidationV1;
|
||||
import com.kingsrook.qbits.edi.map.MapPreviewService;
|
||||
import com.kingsrook.qbits.edi.map.MapRuleValidator;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller-authorized projections and sandbox orchestration, using normal QQQ actions.
|
||||
*******************************************************************************/
|
||||
public class EdiApiService
|
||||
{
|
||||
public static final int MAX_SAMPLE_BYTES = 5 * 1024 * 1024;
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforced reads preserve table permissions, field locks and partner record locks.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table, QQueryFilter filter) throws Exception
|
||||
{
|
||||
return (EdiRecords.queryEnforced(table, filter));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A missing or invisible record has the same response.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(String table, Integer id) throws Exception
|
||||
{
|
||||
if(id == null || id < 1)
|
||||
{
|
||||
throw (new QBadRequestException("A positive identifier is required"));
|
||||
}
|
||||
var records = query(table, EdiRecords.equal("id", id));
|
||||
if(records.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (records.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a guide as a nested tree, preserving duplicate loop ids in separate parents.
|
||||
*******************************************************************************/
|
||||
public EdiGuideTreeV1 tree(Integer id) throws Exception
|
||||
{
|
||||
row("edi_guide", id);
|
||||
List<EdiLoopV1> roots = new ArrayList<>();
|
||||
List<EdiSegmentV1> rootSegments = new ArrayList<>();
|
||||
Map<String, EdiLoopV1> loops = new LinkedHashMap<>();
|
||||
Map<String, String> latestPaths = new LinkedHashMap<>();
|
||||
var segments = query("edi_guide_segment", EdiRecords.equal("guideId", id));
|
||||
segments.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
for(var segment : segments)
|
||||
{
|
||||
List<EdiElementV1> elements = new ArrayList<>();
|
||||
var rows = query("edi_guide_element", EdiRecords.equal("guideSegmentId", segment.getValue("id")));
|
||||
rows.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("position")).thenComparing(r -> Objects.requireNonNullElse(r.getValueInteger("componentPosition"), 0)));
|
||||
for(var element : rows)
|
||||
{
|
||||
List<String> codes = List.of();
|
||||
Integer codeList = element.getValueInteger("codeListId");
|
||||
if(codeList != null)
|
||||
{
|
||||
row("edi_code_list", codeList);
|
||||
codes = query("edi_code_list_value", EdiRecords.equal("codeListId", codeList)
|
||||
.withCriteria("isActive", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true))
|
||||
.stream().map(r -> r.getValueString("code")).sorted().limit(200).toList();
|
||||
}
|
||||
elements.add(new EdiElementV1(element.getValueInteger("position"), element.getValueInteger("componentPosition"),
|
||||
element.getValueString("elementId"), element.getValueString("name"), element.getValueString("dataType"),
|
||||
element.getValueInteger("minLength"), element.getValueInteger("maxLength"), element.getValueString("usage"), codeList, codes));
|
||||
}
|
||||
EdiSegmentV1 node = new EdiSegmentV1(segment.getValueString("segmentId"), segment.getValueString("position"), segment.getValueString("usage"),
|
||||
segment.getValueInteger("minUse"), segment.getValueInteger("maxUse"), segment.getValueString("name"), elements);
|
||||
String loopId = segment.getValueString("loopId");
|
||||
if(loopId == null || loopId.isBlank())
|
||||
{
|
||||
rootSegments.add(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
String parent = latestPaths.get(segment.getValueString("parentLoopId"));
|
||||
String path = (parent == null ? "" : parent + "/") + loopId;
|
||||
EdiLoopV1 loop = loops.get(path);
|
||||
if(loop == null)
|
||||
{
|
||||
loop = new EdiLoopV1(loopId, segment.getValueInteger("loopMaxRepeat"), new ArrayList<>(), new ArrayList<>());
|
||||
loops.put(path, loop);
|
||||
(parent == null ? roots : loops.get(parent).loops()).add(loop);
|
||||
}
|
||||
latestPaths.put(loopId, path);
|
||||
loop.segments().add(node);
|
||||
}
|
||||
}
|
||||
return (new EdiGuideTreeV1(roots, rootSegments));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return visible target metadata, rules and the same registry used by mapping.
|
||||
*******************************************************************************/
|
||||
public EdiTargetV1 target(Integer id) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", id);
|
||||
QTableMetaData table = targetTable(map);
|
||||
List<EdiAssociationV1> associations = new ArrayList<>();
|
||||
if(table.getAssociations() != null)
|
||||
{
|
||||
for(var association : table.getAssociations())
|
||||
{
|
||||
QTableMetaData child = QContext.getQInstance().getTable(association.getAssociatedTableName());
|
||||
associations.add(new EdiAssociationV1(association.getName(), child.getName(), Objects.requireNonNullElse(child.getLabel(), association.getName()), fields(child)));
|
||||
}
|
||||
}
|
||||
List<EdiRuleV1> rules = new ArrayList<>();
|
||||
for(QRecord rule : query("edi_map_rule", EdiRecords.equal("mapId", id)))
|
||||
{
|
||||
rules.add(rule(rule));
|
||||
}
|
||||
rules.sort(Comparator.comparing(r -> Objects.requireNonNullElse(r.sequence(), 0)));
|
||||
return (new EdiTargetV1(table.getName(), fields(table), associations, rules,
|
||||
EdiRuntime.current().transforms().argumentSchemas().entrySet().stream().map(e -> new EdiTransformV1(e.getKey(), e.getValue())).toList()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the configured target without accepting a client-supplied table.
|
||||
*******************************************************************************/
|
||||
private QTableMetaData targetTable(QRecord map) throws Exception
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(map.getValueString("tableName"));
|
||||
if(table == null)
|
||||
{
|
||||
throw (new QBadRequestException("The map target table is unavailable"));
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Table metadata is only exposed with the table's read permission.
|
||||
*******************************************************************************/
|
||||
private List<EdiFieldV1> fields(QTableMetaData table) throws Exception
|
||||
{
|
||||
QueryInput input = new QueryInput(table.getName());
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ);
|
||||
return (table.getFields().values().stream().map(f -> new EdiFieldV1(f.getName(), f.getLabel(), f.getType().name(), f.getIsRequired(), f.getPossibleValueSourceName())).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy only documented rule fields; do not leak persistence or diagnostic fields.
|
||||
*******************************************************************************/
|
||||
private EdiRuleV1 rule(QRecord r)
|
||||
{
|
||||
return (new EdiRuleV1(r.getValueInteger("id"), r.getValueInteger("sequence"), r.getValueString("ediPath"), r.getValueString("qqqPath"),
|
||||
r.getValueString("transform"), r.getValueString("transformArgs"), r.getValueBoolean("required"), r.getValueString("condition"), r.getValueString("defaultValue")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert unsaved edits to detached records; mapId is never accepted from a rule.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> records(List<EdiRuleV1> rules) throws Exception
|
||||
{
|
||||
if(rules == null || rules.size() > 10000 || rules.stream().anyMatch(Objects::isNull))
|
||||
{
|
||||
throw (new QBadRequestException("Supply an array of at most 10000 rules"));
|
||||
}
|
||||
List<QRecord> result = new ArrayList<>();
|
||||
for(EdiRuleV1 rule : rules)
|
||||
{
|
||||
result.add(new QRecord().withValue("id", rule.id()).withValue("sequence", rule.sequence()).withValue("ediPath", rule.ediPath())
|
||||
.withValue("qqqPath", rule.qqqPath()).withValue("transform", rule.transform()).withValue("transformArgs", rule.transformArgs())
|
||||
.withValue("required", rule.required()).withValue("condition", rule.condition()).withValue("defaultValue", rule.defaultValue()));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate all submitted rules, with stable index correlation and no persistence.
|
||||
*******************************************************************************/
|
||||
public EdiValidationV1 validate(Integer id, EdiValidateRequestV1 request) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", id);
|
||||
targetTable(map);
|
||||
var guide = MapPreviewService.guide(map, "ORIGINAL");
|
||||
List<QRecord> rules = records(request.rules());
|
||||
List<EdiRuleErrorsV1> results = new ArrayList<>();
|
||||
for(int i = 0; i < rules.size(); i++)
|
||||
{
|
||||
QRecord rule = rules.get(i);
|
||||
results.add(new EdiRuleErrorsV1(i, rule.getValueInteger("id"), rule.getValueInteger("sequence"),
|
||||
new MapRuleValidator().validate(rule, map, guide, EdiRuntime.current().transforms())));
|
||||
}
|
||||
return (new EdiValidationV1(results));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize before decoding or saving an upload; previews never change map rules.
|
||||
*******************************************************************************/
|
||||
public EdiPreviewV1 preview(EdiPreviewRequestV1 request) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", request.mapId());
|
||||
AbstractActionInput permission = new AbstractActionInput();
|
||||
permission.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkProcessPermissionThrowing(permission, "edi_map_preview");
|
||||
String revision = Objects.requireNonNullElse(request.guideRevision(), "CURRENT");
|
||||
if(!List.of("CURRENT", "ORIGINAL").contains(revision))
|
||||
{
|
||||
throw (new QBadRequestException("guideRevision must be CURRENT or ORIGINAL"));
|
||||
}
|
||||
if((request.sampleId() == null) == (request.sampleBase64() == null))
|
||||
{
|
||||
throw (new QBadRequestException("Supply exactly one of sampleId and sampleBase64"));
|
||||
}
|
||||
List<QRecord> overrides = request.ruleOverrides() == null ? null : records(request.ruleOverrides());
|
||||
QRecord sample;
|
||||
if(request.sampleId() != null)
|
||||
{
|
||||
sample = row("edi_map_sample", request.sampleId());
|
||||
if(!Objects.equals(sample.getValueInteger("mapId"), request.mapId()))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
String encoded = request.sampleBase64();
|
||||
if(encoded.length() > ((MAX_SAMPLE_BYTES + 2) / 3) * 4)
|
||||
{
|
||||
throw (new QBadRequestException("Sample exceeds the 5 MiB limit"));
|
||||
}
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Base64.getDecoder().decode(encoded);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("sampleBase64 must be valid base64"));
|
||||
}
|
||||
if(bytes.length == 0 || bytes.length > MAX_SAMPLE_BYTES)
|
||||
{
|
||||
throw (new QBadRequestException("Sample must contain 1 byte through 5 MiB"));
|
||||
}
|
||||
if(request.sampleName() != null && request.sampleName().length() > 255)
|
||||
{
|
||||
throw (new QBadRequestException("sampleName must be at most 255 characters"));
|
||||
}
|
||||
// As in the record action, only encrypted sample archival uses the EDI writer.
|
||||
sample = SystemWrites.call(() ->
|
||||
{
|
||||
var ref = EdiRuntime.current().payloads().put(PayloadKind.SAMPLE, new ByteArrayInputStream(bytes));
|
||||
try
|
||||
{
|
||||
return (SystemWrites.insert("edi_map_sample", new QRecord().withValue("mapId", request.mapId())
|
||||
.withValue("name", Objects.requireNonNullElse(request.sampleName(), "Synthetic upload")).withValue("payloadRef", ref.value())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
EdiRuntime.current().payloads().delete(ref);
|
||||
throw (e);
|
||||
}
|
||||
});
|
||||
}
|
||||
return (JSON.convertValue(new MapPreviewService().preview(map, sample, overrides, revision), EdiPreviewV1.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned wire types; schemas describe values without exposing QRecord internals.
|
||||
*******************************************************************************/
|
||||
public final class EdiSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static schema catalog.
|
||||
*******************************************************************************/
|
||||
private EdiSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A reference to another versioned wire type.
|
||||
*******************************************************************************/
|
||||
public static Schema ref(String name)
|
||||
{
|
||||
return (new Schema().withRefToSchema(name));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Array schema with an explicit item shape.
|
||||
*******************************************************************************/
|
||||
private static Schema array(Schema item)
|
||||
{
|
||||
return (new Schema().withType(Type.ARRAY).withItems(item));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered guide tree. Root segments are outside loops; nested loops retain parent scope.
|
||||
*******************************************************************************/
|
||||
public record EdiGuideTreeV1(List<EdiLoopV1> loops, List<EdiSegmentV1> segments) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Ordered guide tree. Root segments are outside loops; nested loops retain parent scope.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("loops", array(ref("EdiLoopV1"))),
|
||||
Map.entry("segments", array(ref("EdiSegmentV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Guide loop, nested within its parent; repeat is the maximum occurrence count.
|
||||
*******************************************************************************/
|
||||
public record EdiLoopV1(String id, Integer repeat, List<EdiLoopV1> loops, List<EdiSegmentV1> segments) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Guide loop, nested within its parent; repeat is the maximum occurrence count.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("repeat", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("loops", array(ref("EdiLoopV1"))),
|
||||
Map.entry("segments", array(ref("EdiSegmentV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Segment definition in guide sequence order.
|
||||
*******************************************************************************/
|
||||
public record EdiSegmentV1(String id, String position, String usage, Integer min, Integer max, String name, List<EdiElementV1> elements) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Segment definition in guide sequence order.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("position", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("usage", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("min", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("max", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("elements", array(ref("EdiElementV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Element or composite component; codes contain at most 200 active codes in lexical order.
|
||||
*******************************************************************************/
|
||||
public record EdiElementV1(Integer position, Integer component, String id, String name, String type, Integer minLength, Integer maxLength, String usage, Integer codeListId, List<String> codes) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Element or composite component; codes contain at most 200 active codes in lexical order.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("position", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("component", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("minLength", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("maxLength", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("usage", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("codeListId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("codes", array(new Schema().withType(Type.STRING).withNullable(true)))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Target metadata visible to the caller, current rules and registered transforms.
|
||||
*******************************************************************************/
|
||||
public record EdiTargetV1(String table, List<EdiFieldV1> fields, List<EdiAssociationV1> associations, List<EdiRuleV1> rules, List<EdiTransformV1> transforms) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Target metadata visible to the caller, current rules and registered transforms.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("table", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("fields", array(ref("EdiFieldV1"))),
|
||||
Map.entry("associations", array(ref("EdiAssociationV1"))),
|
||||
Map.entry("rules", array(ref("EdiRuleV1"))),
|
||||
Map.entry("transforms", array(ref("EdiTransformV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ target field metadata.
|
||||
*******************************************************************************/
|
||||
public record EdiFieldV1(String name, String label, String type, Boolean required, String possibleValueSourceName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("QQQ target field metadata.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("required", new Schema().withType(Type.BOOLEAN)),
|
||||
Map.entry("possibleValueSourceName", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct QQQ association and its target fields.
|
||||
*******************************************************************************/
|
||||
public record EdiAssociationV1(String name, String table, String label, List<EdiFieldV1> fields) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Direct QQQ association and its target fields.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("table", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("fields", array(ref("EdiFieldV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Map rule. id is optional for unsaved rules. transformArgs is a JSON object encoded as a string, as in core table CRUD.
|
||||
*******************************************************************************/
|
||||
public record EdiRuleV1(Integer id, Integer sequence, String ediPath, String qqqPath, String transform, String transformArgs, Boolean required, String condition, String defaultValue) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Map rule. id is optional for unsaved rules. transformArgs is a JSON object encoded as a string, as in core table CRUD.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sequence", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("ediPath", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("qqqPath", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("transform", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("transformArgs", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("required", new Schema().withType(Type.BOOLEAN).withNullable(true)),
|
||||
Map.entry("condition", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("defaultValue", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Registered transform and JSON Schema describing its string-valued arguments.
|
||||
*******************************************************************************/
|
||||
public record EdiTransformV1(String name, Map<String, Object> argumentSchema) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Registered transform and JSON Schema describing its string-valued arguments.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("argumentSchema", new Schema().withType(Type.OBJECT).withAdditionalProperties(true).withDescription("JSON Schema: type, properties, required and additionalProperties. All transform arguments are strings."))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** mapId is required. Supply exactly one of sampleId or sampleBase64. Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces the whole rule set in memory. CURRENT (default) uses the newest active guide in the pinned guide family; ORIGINAL uses the pinned guide.
|
||||
*******************************************************************************/
|
||||
public record EdiPreviewRequestV1(Integer mapId, Integer sampleId, String sampleBase64, String sampleName, List<EdiRuleV1> ruleOverrides, String guideRevision) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("mapId is required. Supply exactly one of sampleId or sampleBase64. Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces the whole rule set in memory. CURRENT (default) uses the newest active guide in the pinned guide family; ORIGINAL uses the pinned guide.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("mapId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sampleId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sampleBase64", new Schema().withType(Type.STRING).withNullable(true).withFormat("byte").withMaxLength(6990508)),
|
||||
Map.entry("sampleName", new Schema().withType(Type.STRING).withNullable(true).withMaxLength(255)),
|
||||
Map.entry("ruleOverrides", array(ref("EdiRuleV1")).withNullable(true)),
|
||||
Map.entry("guideRevision", new Schema().withType(Type.STRING).withNullable(true).withEnumValues(List.of("CURRENT", "ORIGINAL")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse, validate, acknowledge and map without target writes. PHI suppresses result, ack text, and both diffs.
|
||||
*******************************************************************************/
|
||||
public record EdiPreviewV1(EdiParseV1 parse, List<EdiFindingV1> findings, EdiAckV1 ack, EdiResultV1 result, EdiDiffV1 diff, Boolean phiSuppressed) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Parse, validate, acknowledge and map without target writes. PHI suppresses result, ack text, and both diffs.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("parse", ref("EdiParseV1")),
|
||||
Map.entry("findings", array(ref("EdiFindingV1"))),
|
||||
Map.entry("ack", ref("EdiAckV1")),
|
||||
Map.entry("result", ref("EdiResultV1").withNullable(true)),
|
||||
Map.entry("diff", ref("EdiDiffV1")),
|
||||
Map.entry("phiSuppressed", new Schema().withType(Type.BOOLEAN))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts of wire interchanges, groups and documents.
|
||||
*******************************************************************************/
|
||||
public record EdiParseV1(Integer interchanges, Integer groups, Integer documents) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Counts of wire interchanges, groups and documents.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("interchanges", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("groups", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("documents", new Schema().withType(Type.INTEGER).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Value-free diagnostic. PHI messages are generic; only positions and codes are retained.
|
||||
*******************************************************************************/
|
||||
public record EdiFindingV1(Integer snipLevel, String severity, Integer segmentPosition, String segmentId, String loopId, Integer elementPosition, String code, String message) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Value-free diagnostic. PHI messages are generic; only positions and codes are retained.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("snipLevel", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("segmentPosition", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("segmentId", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("loopId", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("elementPosition", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("code", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Concatenated acknowledgement previews; type is 997, 999, TA1, CONTRL, NONE or MIXED. text is null for PHI.
|
||||
*******************************************************************************/
|
||||
public record EdiAckV1(String type, String text) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Concatenated acknowledgement previews; type is 997, 999, TA1, CONTRL, NONE or MIXED. text is null for PHI.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("text", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mapped records in document order; no target writes.
|
||||
*******************************************************************************/
|
||||
public record EdiResultV1(List<EdiRecordV1> records) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Mapped records in document order; no target writes.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("records", array(ref("EdiRecordV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Field values and recursively associated records. Values use the target table field types.
|
||||
*******************************************************************************/
|
||||
public record EdiRecordV1(Map<String, Object> values, Map<String, List<EdiRecordV1>> associations) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Field values and recursively associated records. Values use the target table field types.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("values", new Schema().withType(Type.OBJECT).withAdditionalProperties(true)),
|
||||
Map.entry("associations", new Schema().withType(Type.OBJECT).withAdditionalProperties(array(ref("EdiRecordV1"))))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only differing records are returned. Both lists are empty for PHI.
|
||||
*******************************************************************************/
|
||||
public record EdiDiffV1(List<EdiDifferenceV1> againstPreviousRevision, List<EdiDifferenceV1> againstExpected) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Only differing records are returned. Both lists are empty for PHI.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("againstPreviousRevision", array(ref("EdiDifferenceV1"))),
|
||||
Map.entry("againstExpected", array(ref("EdiDifferenceV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One document comparison; documentIndex is zero based.
|
||||
*******************************************************************************/
|
||||
public record EdiDifferenceV1(Integer documentIndex, Object before, Object after) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One document comparison; documentIndex is zero based.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("documentIndex", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("before", new Schema().withNullable(true).withDescription("Expected JSON or a previous mapped record.")),
|
||||
Map.entry("after", new Schema().withType(Type.OBJECT).withAdditionalProperties(true).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** rules is required and may be empty; rules are never persisted.
|
||||
*******************************************************************************/
|
||||
public record EdiValidateRequestV1(List<EdiRuleV1> rules) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("rules is required and may be empty; rules are never persisted.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("rules", array(ref("EdiRuleV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One result per submitted rule, including rules with no errors.
|
||||
*******************************************************************************/
|
||||
public record EdiValidationV1(List<EdiRuleErrorsV1> rules) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One result per submitted rule, including rules with no errors.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("rules", array(ref("EdiRuleErrorsV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Correlate errors using zero-based index, optional id and sequence.
|
||||
*******************************************************************************/
|
||||
public record EdiRuleErrorsV1(Integer index, Integer id, Integer sequence, List<String> errors) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Correlate errors using zero-based index, optional id and sequence.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("index", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sequence", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("errors", array(new Schema().withType(Type.STRING).withNullable(true)))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core middleware error body.
|
||||
*******************************************************************************/
|
||||
public record EdiErrorV1(String error) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete deterministic schema catalog for all four operations.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> all()
|
||||
{
|
||||
Map<String, Schema> result = new TreeMap<>();
|
||||
result.put("EdiGuideTreeV1", new EdiGuideTreeV1(null, null).toSchema());
|
||||
result.put("EdiLoopV1", new EdiLoopV1(null, null, null, null).toSchema());
|
||||
result.put("EdiSegmentV1", new EdiSegmentV1(null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiElementV1", new EdiElementV1(null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiTargetV1", new EdiTargetV1(null, null, null, null, null).toSchema());
|
||||
result.put("EdiFieldV1", new EdiFieldV1(null, null, null, null, null).toSchema());
|
||||
result.put("EdiAssociationV1", new EdiAssociationV1(null, null, null, null).toSchema());
|
||||
result.put("EdiRuleV1", new EdiRuleV1(null, null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiTransformV1", new EdiTransformV1(null, null).toSchema());
|
||||
result.put("EdiPreviewRequestV1", new EdiPreviewRequestV1(null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiPreviewV1", new EdiPreviewV1(null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiParseV1", new EdiParseV1(null, null, null).toSchema());
|
||||
result.put("EdiFindingV1", new EdiFindingV1(null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiAckV1", new EdiAckV1(null, null).toSchema());
|
||||
result.put("EdiResultV1", new EdiResultV1(null).toSchema());
|
||||
result.put("EdiRecordV1", new EdiRecordV1(null, null).toSchema());
|
||||
result.put("EdiDiffV1", new EdiDiffV1(null, null).toSchema());
|
||||
result.put("EdiDifferenceV1", new EdiDifferenceV1(null, null, null).toSchema());
|
||||
result.put("EdiValidateRequestV1", new EdiValidateRequestV1(null).toSchema());
|
||||
result.put("EdiValidationV1", new EdiValidationV1(null).toSchema());
|
||||
result.put("EdiRuleErrorsV1", new EdiRuleErrorsV1(null, null, null, null).toSchema());
|
||||
result.put("EdiErrorV1", new EdiErrorV1(null).toSchema());
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -47,6 +49,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
@@ -56,9 +59,11 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed EDI home. Every data query explicitly enforces the viewing user's access.
|
||||
@@ -67,9 +72,31 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final List<String> ACTIVITY = List.of("edi_transaction", "edi_interchange", "edi_exception");
|
||||
public static final List<String> SETUP = List.of("edi_partner", "edi_agreement", "edi_transport", "edi_certificate", "edi_guide", "edi_code_list", "edi_map");
|
||||
private static final Map<String, String> LABELS = new LinkedHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** System-audience widgets (component and queue health, dead letters,
|
||||
** certificate expiry, setup and configuration). They are not on the EDI
|
||||
** business home: a host places them on its Admin home. Each is gated to
|
||||
** ADMIN_PERMISSION_BASE_NAME and hidden from everyone else.
|
||||
*******************************************************************************/
|
||||
public static final List<String> ADMIN_WIDGET_NAMES = List.of("edi_home_system", "edi_home_dead", "edi_home_certificates", "edi_home_setup");
|
||||
|
||||
/*******************************************************************************
|
||||
** Business widgets on the EDI home, in order. Each is gated to the EDI app
|
||||
** permission (edi.hasAccess) and hidden from everyone else.
|
||||
*******************************************************************************/
|
||||
public static final List<String> HOME_WIDGET_NAMES = List.of("edi_home_delayed", "edi_home_in_today", "edi_home_out_today", "edi_home_exceptions",
|
||||
"edi_home_overdue", "edi_home_queue", "edi_home_acks", "edi_home_errors", "edi_home_volume");
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI administrator permission (edi.admin.hasAccess), as on the admin processes.
|
||||
*******************************************************************************/
|
||||
public static final String ADMIN_PERMISSION_BASE_NAME = EdiQBitConfig.PERMISSION_BASE_NAME + ".admin";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(EdiHome.class);
|
||||
private static final Map<String, String> LABELS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> DOCUMENT_NAMES = new HashMap<>();
|
||||
private static final List<String> TABLES = List.of("queue", "acks", "errors", "volume", "system");
|
||||
private static final List<String> TABLES = List.of("setup", "queue", "acks", "errors", "volume", "system");
|
||||
|
||||
static
|
||||
{
|
||||
@@ -79,12 +106,14 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
Map.entry("835", "Remittance"), Map.entry("270", "Eligibility Inquiry"), Map.entry("271", "Eligibility Response"), Map.entry("276", "Claim Status Inquiry"),
|
||||
Map.entry("277", "Claim Status Response"), Map.entry("834", "Enrollment"), Map.entry("997", "Functional Ack"), Map.entry("999", "Implementation Ack"),
|
||||
Map.entry("TA1", "Interchange Ack")));
|
||||
LABELS.put("delayed", "Inbound documents delayed");
|
||||
LABELS.put("in_today", "Inbound today");
|
||||
LABELS.put("out_today", "Outbound today");
|
||||
LABELS.put("exceptions", "Open exceptions");
|
||||
LABELS.put("overdue", "Overdue acks");
|
||||
LABELS.put("dead", "Dead letters");
|
||||
LABELS.put("certificates", "Certificates expiring (30 days)");
|
||||
LABELS.put("setup", "Setup status");
|
||||
LABELS.put("queue", "Exception queue");
|
||||
LABELS.put("acks", "Overdue acknowledgements");
|
||||
LABELS.put("errors", "Recent errors (24 hours)");
|
||||
@@ -98,7 +127,7 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
public static QAppMetaData app(EdiQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel()).withIcon(new QIcon("swap_horiz"))
|
||||
.withWidgets(LABELS.keySet().stream().map(n -> "edi_home_" + n).toList())
|
||||
.withWidgets(HOME_WIDGET_NAMES)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withSection(new QAppSection().withName("activity").withLabel("Activity").withTables(ACTIVITY))
|
||||
.withSection(new QAppSection().withName("setup").withLabel("Setup").withTables(SETUP));
|
||||
@@ -108,15 +137,23 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 typed statistics and tables; no raw markup in metadata or widget data.
|
||||
** RC.5 typed statistics, tables and one alert. Business widgets need the EDI
|
||||
** app permission; the ADMIN_WIDGET_NAMES need the EDI administrator
|
||||
** permission. Denied widgets are hidden, never shown as locked shells.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
LABELS.forEach((name, label) -> widgets.add(new QWidgetMetaData().withName("edi_home_" + name).withLabel(label)
|
||||
.withType((TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType())
|
||||
.withGridColumns(TABLES.contains(name) ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))));
|
||||
LABELS.forEach((name, label) ->
|
||||
{
|
||||
String widgetName = "edi_home_" + name;
|
||||
boolean admin = ADMIN_WIDGET_NAMES.contains(widgetName);
|
||||
String type = name.equals("delayed") ? WidgetType.ALERT.getType() : (TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType();
|
||||
widgets.add(new QWidgetMetaData().withName(widgetName).withLabel(label).withType(type)
|
||||
.withGridColumns(TABLES.contains(name) || admin || name.equals("delayed") ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
|
||||
.withPermissionRules(new QPermissionRules().withPermissionBaseName(admin ? ADMIN_PERMISSION_BASE_NAME : EdiQBitConfig.PERMISSION_BASE_NAME)
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withDenyBehavior(DenyBehavior.HIDDEN)));
|
||||
});
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
@@ -136,8 +173,10 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
case "exceptions" -> count("edi_exception", openExceptions());
|
||||
case "overdue" -> count("edi_functional_group", overdueAcks(now));
|
||||
case "dead" -> count("edi_exception", openExceptions().withCriteria("type", QCriteriaOperator.IN, "DEAD_LETTER", "TRANSPORT"));
|
||||
case "delayed" -> delayed(now);
|
||||
case "certificates" -> count("edi_certificate", new QQueryFilter().withCriteria("notAfter", QCriteriaOperator.LESS_THAN_OR_EQUALS, now.plus(30, ChronoUnit.DAYS))
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
case "setup" -> setup();
|
||||
case "queue" -> queue(now);
|
||||
case "acks" -> acks(now);
|
||||
case "errors" -> errors(now);
|
||||
@@ -191,6 +230,95 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The setup steps in order, with their status and the next step. Counts are
|
||||
** taken as the viewer; a table the viewer cannot read shows as not visible.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput setup() throws QException
|
||||
{
|
||||
QQueryFilter active = new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true);
|
||||
EdiSetupStatus.Counts counts = new EdiSetupStatus.Counts(safeCount("edi_partner", new QQueryFilter()),
|
||||
safeCount("edi_identity", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true).withCriteria("partnerId", QCriteriaOperator.IS_BLANK)),
|
||||
safeCount("edi_identity", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true).withCriteria("partnerId", QCriteriaOperator.IS_NOT_BLANK)),
|
||||
safeCount("edi_agreement", new QQueryFilter()), safeCount("edi_agreement", active),
|
||||
safeCount("edi_agreement_document", new QQueryFilter()), safeCount("edi_agreement_document", active),
|
||||
safeCount("edi_transport", new QQueryFilter()), safeCount("edi_transport", active),
|
||||
safeCount("edi_certificate", new QQueryFilter()), safeCount("edi_guide", new QQueryFilter()), safeCount("edi_code_list", new QQueryFilter()),
|
||||
safeCount("edi_map", new QQueryFilter()), safeCount("edi_map", new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(EdiSetupStatus.Item item : EdiSetupStatus.items(counts))
|
||||
{
|
||||
rows.add(row("Step", item.step(), "Status", item.state().label() + (item.detail().isEmpty() ? "" : ": " + item.detail()), "Next step", item.nextStep().isEmpty() ? "Nothing to do." : item.nextStep()));
|
||||
}
|
||||
return (table(List.of("Step", "Status", "Next step"), rows, "edi_partner", new QQueryFilter(), "Open partners"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A count that may fail (for example a table the viewer cannot read): null,
|
||||
** shown as "Unavailable", and logged; never a made-up number.
|
||||
*******************************************************************************/
|
||||
static Integer safeCount(String table, QQueryFilter filter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (countOf(table, filter));
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("EDI setup count unavailable", e, logPair("table", table));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The business face of a stalled inbound flow: when the oldest unprocessed
|
||||
** inbound document is older than the configured threshold, a warning with its
|
||||
** age that links to those documents. Hidden when nothing is delayed, or when
|
||||
** the viewer cannot read the inbound documents.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput delayed(Instant now) throws QException
|
||||
{
|
||||
Integer minutes = EdiRuntime.current().config().getInboundDelayedAfterMinutes();
|
||||
Instant cutoff = now.minus(minutes, ChronoUnit.MINUTES);
|
||||
List<QRecord> oldest = List.of();
|
||||
try
|
||||
{
|
||||
oldest = query("edi_inbox", delayedInbound(cutoff).withOrderBy(new QFilterOrderBy("receivedAt", true)).withLimit(1));
|
||||
}
|
||||
catch(QPermissionDeniedException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// omitted, not locked: the viewer cannot open the inbox //
|
||||
///////////////////////////////////////////////////////////
|
||||
}
|
||||
if(oldest.isEmpty())
|
||||
{
|
||||
return (new RenderWidgetOutput(new AlertData().withHideWidget(true)));
|
||||
}
|
||||
int count = countOf("edi_inbox", delayedInbound(cutoff));
|
||||
String age = humanize(Duration.between(oldest.getFirst().getValueInstant("receivedAt"), now));
|
||||
String html = "<strong>Inbound documents are delayed.</strong> " + count + (count == 1 ? " document has" : " documents have")
|
||||
+ " waited more than " + humanize(Duration.ofMinutes(minutes)) + " to be processed; the oldest has waited " + age + ". "
|
||||
+ "<a href=\"" + link("edi_inbox", delayedInbound(cutoff)) + "\">View delayed documents</a>";
|
||||
return (new RenderWidgetOutput(new AlertData(AlertData.AlertType.WARNING, html)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unprocessed inbound documents received before the cutoff.
|
||||
*******************************************************************************/
|
||||
static QQueryFilter delayedInbound(Instant cutoff)
|
||||
{
|
||||
return (inboxBacklog().withCriteria("receivedAt", QCriteriaOperator.LESS_THAN, cutoff));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open exceptions, oldest first, in four narrow columns.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.app;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup status on the EDI home: each setup step in the order it has to be
|
||||
** done (partner, identity, agreement, document, transport), whether it is
|
||||
** done, and the next step. The counts use the same conditions the runtime
|
||||
** uses to route documents (active rows), so the card never reports "set up"
|
||||
** for rows the runtime ignores.
|
||||
*******************************************************************************/
|
||||
public final class EdiSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** DONE: working. TODO: the exchange needs it. BUILT_IN: provided by the
|
||||
** qbit. OPTIONAL: only some setups need it. UNKNOWN: its count failed (for
|
||||
** example the viewer cannot read the table), shown as "Unavailable".
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
DONE("Set up"), TODO("Needs setup"), BUILT_IN("Built in"), OPTIONAL("Optional"), UNKNOWN("Unavailable");
|
||||
|
||||
private final String label;
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
State(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The label shown on the card.
|
||||
*******************************************************************************/
|
||||
public String label()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String step, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The row counts the card is built from; null means the count failed and the
|
||||
** step shows as unavailable. Active counts are rows with isActive true (what
|
||||
** the runtime uses).
|
||||
*******************************************************************************/
|
||||
public record Counts(Integer partners, Integer ownIdentities, Integer partnerIdentities, Integer agreements, Integer activeAgreements, Integer agreementDocuments,
|
||||
Integer activeAgreementDocuments, Integer transports, Integer activeTransports, Integer certificates, Integer guides, Integer codeLists,
|
||||
Integer maps, Integer activeMaps)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EdiSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The items, in setup order.
|
||||
*******************************************************************************/
|
||||
public static List<Item> items(Counts c)
|
||||
{
|
||||
List<Item> items = new ArrayList<>();
|
||||
items.add(item("1. Partner", "edi_partner", c.partners(), c.partners(), "partner", "Add the trading partner with New on Partners."));
|
||||
items.add(identities(c));
|
||||
items.add(item("3. Agreement", "edi_agreement", c.agreements(), c.activeAgreements(), "agreement",
|
||||
"Add an agreement on Agreements: your identity, the partner's identity, the standard and test or production. Make sure Active is on."));
|
||||
items.add(item("4. Agreement documents", "edi_agreement_document", c.agreementDocuments(), c.activeAgreementDocuments(), "agreement document",
|
||||
"Add one per transaction set and direction on Agreement Documents: pick the guide, and a map if it should become records. A document type with no row is not processed."));
|
||||
items.add(item("5. Transport", "edi_transport", c.transports(), c.activeTransports(), "transport",
|
||||
"Add the AS2, SFTP or HTTPS connection on Transports (secrets as variable names), link it on the agreement, then run Test Connection."));
|
||||
items.add(c.certificates() == null ? new Item("Certificates", "edi_certificate", State.UNKNOWN, "", "")
|
||||
: c.certificates() == 0
|
||||
? new Item("Certificates", "edi_certificate", State.OPTIONAL, "None.", "Needed only for AS2 signing and encryption: add yours and the partner's on Certificates.")
|
||||
: new Item("Certificates", "edi_certificate", State.DONE, c.certificates() + (c.certificates() == 1 ? " certificate." : " certificates."), ""));
|
||||
items.add(c.guides() == null || c.codeLists() == null ? new Item("Guides and code lists", "edi_guide", State.UNKNOWN, "", "")
|
||||
: c.guides() == 0 || c.codeLists() == 0
|
||||
? new Item("Guides and code lists", "edi_guide", State.TODO, "The built-in guides are not loaded.", "The host must call EdiRuntime.initialize() at startup, after the schema exists.")
|
||||
: new Item("Guides and code lists", "edi_guide", State.BUILT_IN, c.guides() + " guides and " + c.codeLists() + " code lists.", ""));
|
||||
items.add(c.maps() == null || c.activeMaps() == null ? new Item("Maps", "edi_map", State.UNKNOWN, "", "")
|
||||
: c.maps() == 0
|
||||
? new Item("Maps", "edi_map", State.OPTIONAL, "None.", "Needed to turn documents into records. Run Install sample maps on Maps to start from an example.")
|
||||
: c.activeMaps() == 0
|
||||
? new Item("Maps", "edi_map", State.TODO, c.maps() + " maps, none active.", "Point a map at your table, preview it, then run Activate Map.")
|
||||
: new Item("Maps", "edi_map", State.DONE, c.activeMaps() + " active of " + c.maps() + ".", ""));
|
||||
return (items);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Step 2: your own identity (no partner) and the partner's.
|
||||
*******************************************************************************/
|
||||
private static Item identities(Counts c)
|
||||
{
|
||||
String step = "2. Identities";
|
||||
if(c.ownIdentities() == null || c.partnerIdentities() == null)
|
||||
{
|
||||
return (new Item(step, "edi_identity", State.UNKNOWN, "", ""));
|
||||
}
|
||||
if(c.ownIdentities() == 0)
|
||||
{
|
||||
return (new Item(step, "edi_identity", State.TODO, "None of yours.", "Add your own ISA or UNB qualifier and ID on Identities, with no partner."));
|
||||
}
|
||||
if(c.partnerIdentities() == 0)
|
||||
{
|
||||
return (new Item(step, "edi_identity", State.TODO, "Yours is set; no partner has one.", "Add the partner's qualifier and ID on Identities, with the partner selected."));
|
||||
}
|
||||
return (new Item(step, "edi_identity", State.DONE, c.ownIdentities() + " of yours, " + c.partnerIdentities() + " for partners.", ""));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A step that needs at least one active row.
|
||||
*******************************************************************************/
|
||||
private static Item item(String step, String table, Integer total, Integer active, String noun, String next)
|
||||
{
|
||||
if(total == null || active == null)
|
||||
{
|
||||
return (new Item(step, table, State.UNKNOWN, "", ""));
|
||||
}
|
||||
if(total == 0)
|
||||
{
|
||||
return (new Item(step, table, State.TODO, "None.", next));
|
||||
}
|
||||
if(active == 0)
|
||||
{
|
||||
return (new Item(step, table, State.TODO, total + " " + noun + (total == 1 ? "" : "s") + ", none active.", "Turn Active on for the one to use."));
|
||||
}
|
||||
return (new Item(step, table, State.DONE, active + " active.", ""));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -54,16 +57,33 @@ public final class EdiJournal
|
||||
filter.withCriteria(pair[0].toString(), pair[1] == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, (Integer) pair[1]);
|
||||
}
|
||||
List<QRecord> matches = EdiRecords.query("edi_exception", filter);
|
||||
if(matches.isEmpty())
|
||||
QRecord exception;
|
||||
boolean opened = matches.isEmpty();
|
||||
if(opened)
|
||||
{
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
|
||||
exception = SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
|
||||
.withValue("partnerId", partner).withValue("type", type).withValue("summary", summary).withValue("status", "OPEN").withValue("occurrences", 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord row = matches.getFirst();
|
||||
exception = row;
|
||||
opened = List.of("RESOLVED", "IGNORED").contains(row.getValueString("status"));
|
||||
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("occurrences", row.getValueInteger("occurrences") + 1).withValue("status", "OPEN"));
|
||||
}
|
||||
if(opened && partner != null && List.of("REJECTED_ACK", "DEAD_LETTER").contains(type))
|
||||
{
|
||||
String[] assignees = EdiRecords.query("edi_exception", EdiRecords.equal("partnerId", partner)
|
||||
.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")).stream()
|
||||
.map(row -> row.getValueString("assignedToUserId")).filter(user -> user != null && !user.isBlank()).distinct().toArray(String[]::new);
|
||||
if(assignees.length > 0)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("EDI exception opened")
|
||||
.withBody("An acknowledgement rejection or dead-letter exception needs attention.")
|
||||
.withAudience(NotificationAudience.users(assignees))
|
||||
.withSourceName("edi").withSourceTableName("edi_exception").withSourceRecordId(exception.getValueString("id")));
|
||||
}
|
||||
}
|
||||
event(inbox, interchange, transaction, "EXCEPTION");
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,8 @@ public class InboundProcessor
|
||||
{
|
||||
QRecord sender = row("edi_identity", agreement.getValueInteger("partnerIdentityId"));
|
||||
QRecord receiver = row("edi_identity", agreement.getValueInteger("localIdentityId"));
|
||||
if(sender != null && receiver != null && Objects.equals(sender.getValueInteger("partnerId"), agreement.getValueInteger("partnerId"))
|
||||
if(sender != null && receiver != null && !Boolean.FALSE.equals(sender.getValueBoolean("isActive")) && !Boolean.FALSE.equals(receiver.getValueBoolean("isActive"))
|
||||
&& Objects.equals(sender.getValueInteger("partnerId"), agreement.getValueInteger("partnerId"))
|
||||
&& receiver.getValue("partnerId") == null && x12.isa().value(5).equals(sender.getValueString("interchangeQualifier"))
|
||||
&& x12.isa().value(6).trim().equals(sender.getValueString("interchangeId")) && x12.isa().value(7).equals(receiver.getValueString("interchangeQualifier"))
|
||||
&& x12.isa().value(8).trim().equals(receiver.getValueString("interchangeId")))
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
@@ -30,36 +32,80 @@ import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListe
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
/*******************************************************************************
|
||||
** Transactional source listener and explicit enqueue boundary for MANUAL/ESB.
|
||||
** The listener applies only to tables with an active RECORD_CHANGE definition
|
||||
** (cached per runtime, see {@link OutboundSourceTables}), and to the definition
|
||||
** tables themselves, whose changes invalidate that cache.
|
||||
*******************************************************************************/
|
||||
public class OutboundEnqueue implements RecordChangeListenerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(OutboundEnqueue.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Ignore internal tables and deletes; definitions are read at event time.
|
||||
** Definition changes (any type), and inserts/updates of configured sources.
|
||||
*******************************************************************************/
|
||||
public boolean appliesTo(String table, RecordChangeType type)
|
||||
{
|
||||
return (!table.startsWith("edi_") && type != RecordChangeType.DELETE);
|
||||
EdiRuntime runtime = EdiRuntime.find();
|
||||
if(runtime == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(OutboundSourceTables.DEFINITION_TABLES.contains(table))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(table.startsWith("edi_") || type == RecordChangeType.DELETE)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (runtime.outboundSources().contains(table, QContext.getQBackendTransaction()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// if the definitions can't be read, apply - so the event runs its //
|
||||
// own queries and reports the failure, rather than silently //
|
||||
// dropping outbound work. //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
LOG.warn("Unable to read EDI outbound source tables", e, logPair("table", table));
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the event transaction, even when the caller did not put it in QContext.
|
||||
** Definitions are read once per event, not once per record.
|
||||
*******************************************************************************/
|
||||
public void onRecordsChanged(RecordChangeEvent event) throws QException
|
||||
{
|
||||
if(OutboundSourceTables.DEFINITION_TABLES.contains(event.getTableName()))
|
||||
{
|
||||
EdiRuntime.current().outboundSources().invalidate(event.getTransaction());
|
||||
return;
|
||||
}
|
||||
var previous = QContext.getQBackendTransaction();
|
||||
QContext.setTransaction(event.getTransaction());
|
||||
try
|
||||
{
|
||||
for(QRecord record : event.getRecords())
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
enqueue(event.getTableName(), record, "RECORD_CHANGE", null);
|
||||
}
|
||||
List<QRecord> definitions = definitions(event.getTableName(), OutboundSourceTables.TRIGGER);
|
||||
for(QRecord record : event.getRecords())
|
||||
{
|
||||
enqueue(event.getTableName(), record, null, definitions);
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -72,45 +118,60 @@ public class OutboundEnqueue implements RecordChangeListenerInterface
|
||||
*******************************************************************************/
|
||||
public static Integer enqueue(String table, QRecord source, String trigger, String eventId) throws QException
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
return (SystemWrites.call(() -> enqueue(table, source, eventId, definitions(table, trigger))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active outbound documents for the table whose trigger matches (MANUAL
|
||||
** matches every trigger) and whose agreement is active. Called inside a
|
||||
** {@link SystemWrites#call}.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> definitions(String table, String trigger) throws QException
|
||||
{
|
||||
List<QRecord> matching = new ArrayList<>();
|
||||
for(QRecord document : EdiRecords.query("edi_agreement_document", EdiRecords.equal("sourceTableName", table)
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)))
|
||||
{
|
||||
int count = 0;
|
||||
var definitions = EdiRecords.query("edi_agreement_document", EdiRecords.equal("sourceTableName", table)
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true));
|
||||
for(QRecord document : definitions)
|
||||
if("MANUAL".equals(trigger) || trigger.equals(document.getValueString("outboundTrigger")))
|
||||
{
|
||||
if(!"MANUAL".equals(trigger) && !trigger.equals(document.getValueString("outboundTrigger")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord agreement = OutboundWorker.row("edi_agreement", document.getValueInteger("agreementId"));
|
||||
if(!Boolean.TRUE.equals(agreement.getValueBoolean("isActive")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var metadata = QContext.getQInstance().getTable(table);
|
||||
if(!metadata.getBackendName().equals(EdiRuntime.current().config().getBackendName()))
|
||||
{
|
||||
throw new QException("Outbound source and EDI must share the transactional backend");
|
||||
}
|
||||
String filter = document.getValueString("triggerFilter");
|
||||
if(filter != null && !filter.isBlank() && EdiRecords.query(table, EdiRecords.equal(metadata.getPrimaryKeyField(), source.getValue(metadata.getPrimaryKeyField()))
|
||||
.withSubFilter(new ObjectMapper().readValue(filter, QQueryFilter.class))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(eventId != null && !EdiRecords.query("edi_outbound_request", EdiRecords.equal("eventId", eventId)
|
||||
.withCriteria("agreementDocumentId", QCriteriaOperator.EQUALS, document.getValue("id"))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SystemWrites.insert("edi_outbound_request", new QRecord().withValue("agreementDocumentId", document.getValue("id"))
|
||||
.withValue("sourceTableName", table).withValue("sourceRecordId", source.getValueString(metadata.getPrimaryKeyField()))
|
||||
.withValue("eventId", eventId).withValue("status", "PENDING").withValue("attempts", 0)
|
||||
.withValue("nextAttemptAt", EdiRuntime.current().config().getClock().instant()));
|
||||
count++;
|
||||
matching.add(document);
|
||||
}
|
||||
return (count);
|
||||
}));
|
||||
}
|
||||
Set<Integer> activeAgreements = OutboundSourceTables.activeAgreementIds(matching);
|
||||
matching.removeIf(document -> !activeAgreements.contains(document.getValueInteger("agreementId")));
|
||||
return (matching);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue one source record against already-resolved definitions.
|
||||
*******************************************************************************/
|
||||
private static int enqueue(String table, QRecord source, String eventId, List<QRecord> definitions) throws Exception
|
||||
{
|
||||
int count = 0;
|
||||
for(QRecord document : definitions)
|
||||
{
|
||||
var metadata = QContext.getQInstance().getTable(table);
|
||||
if(!metadata.getBackendName().equals(EdiRuntime.current().config().getBackendName()))
|
||||
{
|
||||
throw new QException("Outbound source and EDI must share the transactional backend");
|
||||
}
|
||||
String filter = document.getValueString("triggerFilter");
|
||||
if(filter != null && !filter.isBlank() && EdiRecords.query(table, EdiRecords.equal(metadata.getPrimaryKeyField(), source.getValue(metadata.getPrimaryKeyField()))
|
||||
.withSubFilter(new ObjectMapper().readValue(filter, QQueryFilter.class))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(eventId != null && !EdiRecords.query("edi_outbound_request", EdiRecords.equal("eventId", eventId)
|
||||
.withCriteria("agreementDocumentId", QCriteriaOperator.EQUALS, document.getValue("id"))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SystemWrites.insert("edi_outbound_request", new QRecord().withValue("agreementDocumentId", document.getValue("id"))
|
||||
.withValue("sourceTableName", table).withValue("sourceRecordId", source.getValueString(metadata.getPrimaryKeyField()))
|
||||
.withValue("eventId", eventId).withValue("status", "PENDING").withValue("attempts", 0)
|
||||
.withValue("nextAttemptAt", EdiRuntime.current().config().getClock().instant()));
|
||||
count++;
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-runtime cache of the source tables that have an active RECORD_CHANGE
|
||||
** outbound definition on an active agreement. The record-change listener
|
||||
** consults it so writes to every other table (audits, jobs, alerts...) cost
|
||||
** nothing. It is loaded from committed data only (never in a writer's
|
||||
** transaction). A change to an agreement or document invalidates it, and
|
||||
** again once that change commits (discarding any reload that raced it); until
|
||||
** then the changing transaction bypasses the cache, so it sees its own
|
||||
** definitions. A short TTL covers changes made by another process.
|
||||
*******************************************************************************/
|
||||
public final class OutboundSourceTables
|
||||
{
|
||||
static final String TRIGGER = "RECORD_CHANGE";
|
||||
static final Set<String> DEFINITION_TABLES = Set.of("edi_agreement", "edi_agreement_document");
|
||||
|
||||
private static final Duration TTL = Duration.ofSeconds(60);
|
||||
|
||||
private final AtomicLong generation = new AtomicLong();
|
||||
private final AtomicLong loads = new AtomicLong();
|
||||
private final Set<QBackendTransaction> changingTransactions = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
|
||||
private volatile Snapshot snapshot;
|
||||
|
||||
/*******************************************************************************
|
||||
** Loaded table names, with the generation and time they were loaded at.
|
||||
*******************************************************************************/
|
||||
private record Snapshot(Set<String> tables, long generation, long loadedAtNanos)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether record changes to this table, written in this transaction (may be
|
||||
** null), can enqueue outbound work.
|
||||
*******************************************************************************/
|
||||
public boolean contains(String table, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(transaction != null && changingTransactions.contains(transaction))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
Snapshot current = snapshot;
|
||||
if(current == null || current.generation() != generation.get() || System.nanoTime() - current.loadedAtNanos() > TTL.toNanos())
|
||||
{
|
||||
current = load();
|
||||
}
|
||||
return (current.tables().contains(table));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cache now, and again once the changing transaction commits.
|
||||
*******************************************************************************/
|
||||
public void invalidate(QBackendTransaction transaction)
|
||||
{
|
||||
generation.incrementAndGet();
|
||||
if(transaction != null)
|
||||
{
|
||||
changingTransactions.add(transaction);
|
||||
transaction.addAfterCommitCallback(() ->
|
||||
{
|
||||
changingTransactions.remove(transaction);
|
||||
generation.incrementAndGet();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How many times the definitions have been loaded (for tests and diagnostics).
|
||||
*******************************************************************************/
|
||||
public long loadCount()
|
||||
{
|
||||
return (loads.get());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read committed active definitions once. A load that raced an invalidation
|
||||
** is not kept as current, because it carries the older generation.
|
||||
*******************************************************************************/
|
||||
private Snapshot load() throws QException
|
||||
{
|
||||
long loadGeneration = generation.get();
|
||||
Set<String> tables = SystemWrites.call(() ->
|
||||
{
|
||||
List<QRecord> documents = committed("edi_agreement_document", new QQueryFilter()
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("outboundTrigger", QCriteriaOperator.EQUALS, TRIGGER));
|
||||
Set<Integer> agreementIds = agreementIds(documents);
|
||||
Set<Integer> activeAgreements = new HashSet<>();
|
||||
if(!agreementIds.isEmpty())
|
||||
{
|
||||
committed("edi_agreement", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("id", QCriteriaOperator.IN, agreementIds)).forEach(agreement -> activeAgreements.add(agreement.getValueInteger("id")));
|
||||
}
|
||||
Set<String> result = new HashSet<>();
|
||||
for(QRecord document : documents)
|
||||
{
|
||||
if(document.getValueString("sourceTableName") != null && activeAgreements.contains(document.getValueInteger("agreementId")))
|
||||
{
|
||||
result.add(document.getValueString("sourceTableName"));
|
||||
}
|
||||
}
|
||||
return (Set.copyOf(result));
|
||||
});
|
||||
loads.incrementAndGet();
|
||||
Snapshot loaded = new Snapshot(tables, loadGeneration, System.nanoTime());
|
||||
snapshot = loaded;
|
||||
return (loaded);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query committed rows - deliberately without the caller's transaction.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> committed(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The distinct non-null agreement ids the documents reference.
|
||||
*******************************************************************************/
|
||||
private static Set<Integer> agreementIds(List<QRecord> documents)
|
||||
{
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
documents.forEach(document -> ids.add(document.getValueInteger("agreementId")));
|
||||
ids.remove(null);
|
||||
return (ids);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active agreement ids among those the documents reference, in one query in
|
||||
** the current transaction.
|
||||
*******************************************************************************/
|
||||
static Set<Integer> activeAgreementIds(List<QRecord> documents) throws QException
|
||||
{
|
||||
Set<Integer> ids = agreementIds(documents);
|
||||
Set<Integer> active = new HashSet<>();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
for(QRecord agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("isActive", true).withCriteria("id", QCriteriaOperator.IN, ids)))
|
||||
{
|
||||
active.add(agreement.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
return (active);
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,15 @@ public class OutboundWorker
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
QRecord localIdentity = row("edi_identity", agreement.getValueInteger("localIdentityId"));
|
||||
QRecord partnerIdentity = row("edi_identity", agreement.getValueInteger("partnerIdentityId"));
|
||||
if(localIdentity == null || partnerIdentity == null || Boolean.FALSE.equals(localIdentity.getValueBoolean("isActive")) || Boolean.FALSE.equals(partnerIdentity.getValueBoolean("isActive")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// an inactive identity stops sending, as it stops routing; requests wait //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
return (0);
|
||||
}
|
||||
var requests = EdiRecords.query("edi_outbound_request", EdiRecords.equal("agreementDocumentId", id).withCriteria("status", QCriteriaOperator.EQUALS, "PENDING").withLimit(100));
|
||||
if(requests.isEmpty())
|
||||
{
|
||||
|
||||
@@ -47,24 +47,37 @@ public class GuideRepository
|
||||
*******************************************************************************/
|
||||
public Guide load(Integer id) throws Exception
|
||||
{
|
||||
QRecord guide = rows("edi_guide", "id", id).getFirst();
|
||||
List<QRecord> definitions = new ArrayList<>(rows("edi_guide_segment", "guideId", id));
|
||||
return (load(id, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** UI preview explicitly enforces every guide read, independently of host defaults.
|
||||
*******************************************************************************/
|
||||
public Guide load(Integer id, boolean enforcePermissions) throws Exception
|
||||
{
|
||||
var guideRows = rows("edi_guide", "id", id, enforcePermissions);
|
||||
if(guideRows.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord guide = guideRows.getFirst();
|
||||
List<QRecord> definitions = new ArrayList<>(rows("edi_guide_segment", "guideId", id, enforcePermissions));
|
||||
definitions.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
List<GuideSegment> segments = new ArrayList<>();
|
||||
List<QRecord> rules = rows("edi_guide_rule", "guideId", id);
|
||||
List<QRecord> rules = rows("edi_guide_rule", "guideId", id, enforcePermissions);
|
||||
for(QRecord definition : definitions)
|
||||
{
|
||||
Integer segmentId = definition.getValueInteger("id");
|
||||
List<GuideElement> elements = new ArrayList<>();
|
||||
for(QRecord element : rows("edi_guide_element", "guideSegmentId", segmentId))
|
||||
for(QRecord element : rows("edi_guide_element", "guideSegmentId", segmentId, enforcePermissions))
|
||||
{
|
||||
List<String> codes = null;
|
||||
QRecord codeList = null;
|
||||
if(element.getValueInteger("codeListId") != null)
|
||||
{
|
||||
codeList = rows("edi_code_list", "id", element.getValueInteger("codeListId")).getFirst();
|
||||
codeList = rows("edi_code_list", "id", element.getValueInteger("codeListId"), enforcePermissions).getFirst();
|
||||
codes = new ArrayList<>();
|
||||
for(QRecord code : rows("edi_code_list_value", "codeListId", element.getValueInteger("codeListId")))
|
||||
for(QRecord code : rows("edi_code_list_value", "codeListId", element.getValueInteger("codeListId"), enforcePermissions))
|
||||
{
|
||||
if(Boolean.TRUE.equals(code.getValueBoolean("isActive")))
|
||||
{
|
||||
@@ -206,9 +219,9 @@ public class GuideRepository
|
||||
/*******************************************************************************
|
||||
** Query normalized child records.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> rows(String table, String field, Integer id) throws Exception
|
||||
private static List<QRecord> rows(String table, String field, Integer id, boolean enforcePermissions) throws Exception
|
||||
{
|
||||
return (EdiRecords.query(table, EdiRecords.equal(field, id)));
|
||||
return (enforcePermissions ? EdiRecords.queryEnforced(table, EdiRecords.equal(field, id)) : EdiRecords.query(table, EdiRecords.equal(field, id)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -121,6 +121,10 @@ public class InboundMapper
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new IllegalArgumentException("Mapping failed at rule " + rule.getValueInteger("sequence") + " (" + rule.getValueString("ediPath") + " -> " + rule.getValueString("qqqPath") + ")");
|
||||
|
||||
@@ -20,183 +20,47 @@
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** No-op apply preview with ordinary Result lines; PHI never enters process output.
|
||||
** Standard record-action presentation of the shared sandbox preview.
|
||||
*******************************************************************************/
|
||||
public class MapPreview
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Parse, validate, acknowledge and map a stored synthetic sample without target writes.
|
||||
** Render the same pipeline as HTTP using ordinary Review/Result lines.
|
||||
*******************************************************************************/
|
||||
public ArrayList<ProcessSummaryLineInterface> preview(QRecord map, QRecord sample) throws Exception
|
||||
{
|
||||
EdiRuntime runtime = EdiRuntime.current();
|
||||
boolean phi = List.of("837", "835", "270", "271", "276", "277", "834").contains(map.getValueString("transactionSet"));
|
||||
phi = phi || com.kingsrook.qbits.edi.support.SystemWrites.call(() -> !EdiRecords.query("edi_agreement_document", EdiRecords.equal("mapId", map.getValue("id"))
|
||||
.withCriteria("containsPhi", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true)).isEmpty());
|
||||
var guide = new GuideRepository().load(map.getValueInteger("guideId"));
|
||||
Map<String, Object> preview = new MapPreviewService().preview(map, sample, null, "ORIGINAL");
|
||||
var json = new ObjectMapper().findAndRegisterModules();
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
try(var logging = com.kingsrook.qbits.edi.security.PhiLogScope.open(phi); InputStream stream = runtime.payloads().open(new PayloadRef(sample.getValueString("payloadRef"), null, 0)))
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "Parsed: " + json.writeValueAsString(preview.get("parse"))));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "SNIP findings: " + json.writeValueAsString(preview.get("findings"))));
|
||||
if(Boolean.TRUE.equals(preview.get("phiSuppressed")))
|
||||
{
|
||||
if("EDIFACT".equals(map.getValueString("standard")))
|
||||
{
|
||||
return (edifact(map, sample, stream, phi));
|
||||
}
|
||||
var interchanges = runtime.reader().read(stream);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchanges.size(), "interchanges parsed"));
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchange.groups().size(), "functional groups parsed"));
|
||||
for(var group : interchange.groups())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, group.transactions().size(), "transaction sets parsed"));
|
||||
Map<String, List<com.kingsrook.qbits.edi.guide.ValidationFinding>> all = new java.util.LinkedHashMap<>();
|
||||
for(var transaction : group.transactions())
|
||||
{
|
||||
var findings = runtime.validator().validate(transaction, guide, phi);
|
||||
all.put(transaction.st().value(2), findings);
|
||||
for(int snip = 1; snip <= 7; snip++)
|
||||
{
|
||||
int level = snip;
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, (int) findings.stream().filter(f -> f.snipLevel() == level).count(), "findings at SNIP " + snip));
|
||||
}
|
||||
List<QRecord> rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
|
||||
rules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
var mapped = InboundApplier.asService(runtime.config().getServiceSessionSupplier(), () -> new InboundMapper(runtime.transforms()).map(transaction, guide, map, rules));
|
||||
if(phi)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
}
|
||||
else
|
||||
{
|
||||
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
String actual = resultJson(mapped.record());
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, actual));
|
||||
String expected = sample.getValueString("expectedResultJson");
|
||||
if(expected != null)
|
||||
{
|
||||
diff(lines, "Expected result", json.readTree(expected), json.readTree(actual));
|
||||
}
|
||||
Integer revision = map.getValueInteger("revision");
|
||||
var previous = EdiRecords.query("edi_map", EdiRecords.equal("name", map.getValue("name")));
|
||||
previous.removeIf(r -> !java.util.Objects.equals(r.getValue("partnerId"), map.getValue("partnerId"))
|
||||
|| revision == null || r.getValueInteger("revision") == null || r.getValueInteger("revision") >= revision);
|
||||
previous.sort(java.util.Comparator.comparing((QRecord r) -> r.getValueInteger("revision")).reversed());
|
||||
if(!previous.isEmpty())
|
||||
{
|
||||
QRecord prior = previous.getFirst();
|
||||
var priorGuide = new GuideRepository().load(prior.getValueInteger("guideId"));
|
||||
List<QRecord> priorRules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", prior.getValue("id")));
|
||||
priorRules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
var priorResult = InboundApplier.asService(runtime.config().getServiceSessionSupplier(),
|
||||
() -> new InboundMapper(runtime.transforms()).map(transaction, priorGuide, prior, priorRules));
|
||||
diff(lines, "Previous revision " + prior.getValueInteger("revision"), json.readTree(resultJson(priorResult.record())), json.readTree(actual));
|
||||
}
|
||||
}
|
||||
}
|
||||
var ack = phi && group.gs().value(8).startsWith("005010X") ? runtime.ack999().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001")
|
||||
: runtime.ack997().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001");
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, phi ? "Acknowledgement preview suppressed for PHI document"
|
||||
: runtime.writer().write(com.kingsrook.qbits.edi.ack.AckEnvelope.wrap(interchange, group, ack, "1", "1", java.time.LocalDateTime.now(runtime.config().getClock())))));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview native EDIFACT messages and CONTRL without applying target records.
|
||||
*******************************************************************************/
|
||||
private ArrayList<ProcessSummaryLineInterface> edifact(QRecord map, QRecord sample, InputStream stream, boolean phi) throws Exception
|
||||
{
|
||||
var runtime = EdiRuntime.current();
|
||||
var guide = new GuideRepository().load(map.getValueInteger("guideId"));
|
||||
var rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
|
||||
rules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
for(var interchange : new com.kingsrook.qbits.edi.edifact.EdifactEnvelopeReader(runtime.config().getMaxSegmentsPerTransaction()).read(stream))
|
||||
{
|
||||
Map<String, List<com.kingsrook.qbits.edi.guide.ValidationFinding>> findings = new java.util.LinkedHashMap<>();
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchange.findings().size(), "envelope findings"));
|
||||
for(var message : interchange.allMessages())
|
||||
{
|
||||
var result = runtime.validator().validate(message, guide, phi);
|
||||
findings.put(message.reference(), result);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, result.size(), "validation findings"));
|
||||
var mapped = InboundApplier.asService(runtime.config().getServiceSessionSupplier(), () -> new InboundMapper(runtime.transforms()).map(message, guide, map, rules));
|
||||
if(phi)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
continue;
|
||||
}
|
||||
String actual = resultJson(mapped.record());
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, actual));
|
||||
if(sample.getValueString("expectedResultJson") != null)
|
||||
{
|
||||
diff(lines, "Expected result", new ObjectMapper().readTree(sample.getValueString("expectedResultJson")), new ObjectMapper().readTree(actual));
|
||||
}
|
||||
}
|
||||
if(interchange.unb() != null && !phi)
|
||||
{
|
||||
var ack = new com.kingsrook.qbits.edi.edifact.ContrlBuilder().build(interchange, findings, AckLevel.ALL_TRANSACTIONS, "1", map.getValueString("version"));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, new com.kingsrook.qbits.edi.edifact.EdifactWriter().write(ack.segments(), interchange.separators(), "")));
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable record JSON excludes QRecord diagnostics and implementation metadata.
|
||||
*******************************************************************************/
|
||||
private static String resultJson(QRecord record) throws Exception
|
||||
{
|
||||
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("values", record.getValues());
|
||||
Map<String, Object> associations = new java.util.LinkedHashMap<>();
|
||||
if(record.getAssociatedRecords() != null)
|
||||
{
|
||||
for(var entry : record.getAssociatedRecords().entrySet())
|
||||
{
|
||||
List<Object> children = new ArrayList<>();
|
||||
for(QRecord child : entry.getValue())
|
||||
{
|
||||
children.add(new ObjectMapper().readTree(resultJson(child)));
|
||||
}
|
||||
associations.put(entry.getKey(), children);
|
||||
}
|
||||
}
|
||||
result.put("associations", associations);
|
||||
return (new ObjectMapper().findAndRegisterModules().writeValueAsString(result));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Show a structured before/after diff for synthetic samples only.
|
||||
*******************************************************************************/
|
||||
private static void diff(ArrayList<ProcessSummaryLineInterface> lines, String label, com.fasterxml.jackson.databind.JsonNode before, com.fasterxml.jackson.databind.JsonNode after) throws Exception
|
||||
{
|
||||
if(before.equals(after))
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + " matches"));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "Acknowledgement preview suppressed for PHI document"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Map<String, Object> difference = Map.of("comparison", label, "before", before, "after", after);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + " differs: " + new ObjectMapper().writeValueAsString(difference)));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, json.writeValueAsString(preview.get("result"))));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, json.writeValueAsString(preview.get("ack"))));
|
||||
Map<?, ?> diff = (Map<?, ?>) preview.get("diff");
|
||||
for(String key : List.of("againstExpected", "againstPreviousRevision"))
|
||||
{
|
||||
String label = key.equals("againstExpected") ? "Expected result" : "Previous revision";
|
||||
List<?> differences = (List<?>) diff.get(key);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + (differences.isEmpty() ? " matches" : " differs: " + json.writeValueAsString(differences))));
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.ack.AckEnvelope;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.document.EdiDocument;
|
||||
import com.kingsrook.qbits.edi.edifact.ContrlBuilder;
|
||||
import com.kingsrook.qbits.edi.edifact.EdifactEnvelopeReader;
|
||||
import com.kingsrook.qbits.edi.edifact.EdifactWriter;
|
||||
import com.kingsrook.qbits.edi.guide.Guide;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.guide.ValidationFinding;
|
||||
import com.kingsrook.qbits.edi.security.PhiLogScope;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared no-apply preview pipeline. All mapping, including LOOKUP, uses the caller.
|
||||
** This service has no optional middleware dependency.
|
||||
*******************************************************************************/
|
||||
public class MapPreviewService
|
||||
{
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
/*******************************************************************************
|
||||
** Read ordered persisted rules, or order a copy of the unsaved replacement set.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> rules(QRecord map, List<QRecord> overrides) throws Exception
|
||||
{
|
||||
List<QRecord> result = new ArrayList<>(overrides == null ? EdiRecords.queryEnforced("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id"))) : overrides);
|
||||
result.sort(Comparator.comparing(r -> Objects.requireNonNullElse(r.getValueInteger("sequence"), 0)));
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** PHI classification cannot be weakened by the caller or by hidden agreements.
|
||||
*******************************************************************************/
|
||||
public static boolean containsPhi(QRecord map) throws Exception
|
||||
{
|
||||
return (healthcare(map.getValueString("transactionSet")) || SystemWrites.call(() -> !EdiRecords.query("edi_agreement_document",
|
||||
EdiRecords.equal("mapId", map.getValue("id")).withCriteria("containsPhi", QCriteriaOperator.EQUALS, true)).isEmpty()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Healthcare document types always suppress values, even with a mismatched map.
|
||||
*******************************************************************************/
|
||||
private static boolean healthcare(String type)
|
||||
{
|
||||
return (type != null && List.of("837", "835", "270", "271", "276", "277", "834").contains(type));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** ORIGINAL is the pinned row; CURRENT follows only the same guide family.
|
||||
*******************************************************************************/
|
||||
public static Guide guide(QRecord map, String revision) throws Exception
|
||||
{
|
||||
Integer id = map.getValueInteger("guideId");
|
||||
if("CURRENT".equals(revision))
|
||||
{
|
||||
var pinnedRows = EdiRecords.queryEnforced("edi_guide", EdiRecords.equal("id", id));
|
||||
if(pinnedRows.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord pinned = pinnedRows.getFirst();
|
||||
var candidates = EdiRecords.queryEnforced("edi_guide", EdiRecords.equal("name", pinned.getValue("name")).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
candidates.removeIf(r -> !Objects.equals(r.getValue("partnerId"), pinned.getValue("partnerId"))
|
||||
|| !Objects.equals(r.getValue("standard"), pinned.getValue("standard"))
|
||||
|| !Objects.equals(r.getValue("version"), pinned.getValue("version"))
|
||||
|| !Objects.equals(r.getValue("transactionSet"), pinned.getValue("transactionSet")));
|
||||
candidates.sort(Comparator.comparing((QRecord r) -> Objects.requireNonNullElse(r.getValueInteger("revision"), 0)).reversed());
|
||||
if(!candidates.isEmpty())
|
||||
{
|
||||
id = candidates.getFirst().getValueInteger("id");
|
||||
}
|
||||
}
|
||||
return (new GuideRepository().load(id, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse and inspect the complete sample before exposing any values.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> preview(QRecord map, QRecord sample, List<QRecord> overrides, String revision) throws Exception
|
||||
{
|
||||
EdiRuntime runtime = EdiRuntime.current();
|
||||
boolean phi = containsPhi(map);
|
||||
Guide guide = guide(map, revision);
|
||||
List<QRecord> rules = rules(map, overrides);
|
||||
State state = new State();
|
||||
try(var logging = PhiLogScope.open(true); InputStream stream = SystemWrites.call(() -> runtime.payloads().open(new PayloadRef(sample.getValueString("payloadRef"), null, 0))))
|
||||
{
|
||||
if("EDIFACT".equals(map.getValueString("standard")))
|
||||
{
|
||||
var interchanges = new EdifactEnvelopeReader(runtime.config().getMaxSegmentsPerTransaction()).read(stream);
|
||||
state.interchanges = interchanges.size();
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
state.groups += interchange.groups().size();
|
||||
state.envelope(interchange.findings());
|
||||
interchange.groups().forEach(group -> state.envelope(group.findings()));
|
||||
Map<String, List<ValidationFinding>> all = new LinkedHashMap<>();
|
||||
for(var message : interchange.allMessages())
|
||||
{
|
||||
all.put(message.reference(), document(state, map, sample, rules, guide, message, phi));
|
||||
}
|
||||
if(interchange.unb() != null)
|
||||
{
|
||||
var ack = new ContrlBuilder().build(interchange, all, AckLevel.ALL_TRANSACTIONS, "1", map.getValueString("version"));
|
||||
state.ack("CONTRL", phi ? null : new EdifactWriter().write(ack.segments(), interchange.separators(), ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var interchanges = runtime.reader().read(stream);
|
||||
phi = phi || interchanges.stream().flatMap(i -> i.groups().stream()).flatMap(g -> g.transactions().stream()).anyMatch(t -> healthcare(t.st().value(1)));
|
||||
state.interchanges = interchanges.size();
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
state.groups += interchange.groups().size();
|
||||
state.envelope(interchange.findings());
|
||||
if(!interchange.findings().isEmpty())
|
||||
{
|
||||
var ta1 = runtime.ta1().build(interchange.isa(), interchange.findings());
|
||||
var isa = AckEnvelope.replyIsa(interchange.isa(), "1", java.time.LocalDateTime.now(runtime.config().getClock()));
|
||||
var reply = new com.kingsrook.qbits.edi.x12.X12Interchange(interchange.separators(), isa,
|
||||
com.kingsrook.qbits.edi.x12.X12Segment.of("IEA", 3, "0", isa.value(13)), List.of(), List.of(ta1), List.of());
|
||||
state.ack("TA1", phi ? null : runtime.writer().write(reply));
|
||||
}
|
||||
for(var group : interchange.groups())
|
||||
{
|
||||
state.envelope(group.findings());
|
||||
Map<String, List<ValidationFinding>> all = new LinkedHashMap<>();
|
||||
for(var transaction : group.transactions())
|
||||
{
|
||||
all.put(transaction.st().value(2), document(state, map, sample, rules, guide, transaction, phi));
|
||||
}
|
||||
boolean use999 = phi && group.gs().value(8).startsWith("005010X");
|
||||
var ack = use999 ? runtime.ack999().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001") : runtime.ack997().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001");
|
||||
state.ack(use999 ? "999" : "997", phi ? null : runtime.writer().write(AckEnvelope.wrap(interchange, group, ack, "1", "1", java.time.LocalDateTime.now(runtime.config().getClock()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Parser, custom transform and expected-JSON diagnostics may contain payload values.
|
||||
throw (new QBadRequestException("Sample could not be previewed; check the document, guide and rules."));
|
||||
}
|
||||
return (object("parse", Map.of("interchanges", state.interchanges, "groups", state.groups, "documents", state.documents),
|
||||
"findings", state.findings, "ack", object("type", state.ackType, "text", phi ? null : state.ackText.toString()),
|
||||
"result", phi ? null : Map.of("records", state.records), "diff", Map.of("againstPreviousRevision", state.previous, "againstExpected", state.expected), "phiSuppressed", phi));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and map one document, collecting only value-free findings for PHI.
|
||||
*******************************************************************************/
|
||||
private List<ValidationFinding> document(State state, QRecord map, QRecord sample, List<QRecord> rules, Guide guide, EdiDocument document, boolean phi) throws Exception
|
||||
{
|
||||
int index = state.documents++;
|
||||
state.envelope(document.findings());
|
||||
var findings = EdiRuntime.current().validator().validate(document, guide, phi);
|
||||
for(var finding : findings)
|
||||
{
|
||||
state.findings.add(object("snipLevel", finding.snipLevel(), "severity", finding.severity(), "segmentPosition", finding.segmentPosition(),
|
||||
"segmentId", finding.segmentId(), "loopId", finding.loopId(), "elementPosition", finding.elementPosition(),
|
||||
"code", finding.elementPosition() == null ? finding.ak3Code() : finding.ak4Code(), "message", phi ? "Validation finding" : finding.message()));
|
||||
}
|
||||
QRecord mapped;
|
||||
try
|
||||
{
|
||||
mapped = new InboundMapper(EdiRuntime.current().transforms()).map(document, guide, map, rules).record();
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state.findings.add(object("snipLevel", 0, "severity", "ERROR", "segmentPosition", null, "segmentId", null, "loopId", null,
|
||||
"elementPosition", null, "code", "MAP", "message", "Mapping failed; check the rule paths and transform arguments"));
|
||||
return (findings);
|
||||
}
|
||||
if(!phi)
|
||||
{
|
||||
Map<String, Object> actual = record(mapped);
|
||||
state.records.add(actual);
|
||||
String expected = sample.getValueString("expectedResultJson");
|
||||
if(expected != null)
|
||||
{
|
||||
difference(state.expected, index, JSON.readValue(expected, Object.class), actual);
|
||||
}
|
||||
Integer revision = map.getValueInteger("revision");
|
||||
var previous = EdiRecords.queryEnforced("edi_map", EdiRecords.equal("name", map.getValue("name")));
|
||||
previous.removeIf(r -> !Objects.equals(r.getValue("partnerId"), map.getValue("partnerId"))
|
||||
|| !Objects.equals(r.getValue("standard"), map.getValue("standard")) || !Objects.equals(r.getValue("transactionSet"), map.getValue("transactionSet"))
|
||||
|| !Objects.equals(r.getValue("direction"), map.getValue("direction")) || !Objects.equals(r.getValue("version"), map.getValue("version"))
|
||||
|| !Objects.equals(r.getValue("tableName"), map.getValue("tableName"))
|
||||
|| revision == null || r.getValueInteger("revision") == null || r.getValueInteger("revision") >= revision);
|
||||
previous.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("revision")).reversed());
|
||||
if(!previous.isEmpty() && !containsPhi(previous.getFirst()))
|
||||
{
|
||||
QRecord prior = previous.getFirst();
|
||||
QRecord priorResult = new InboundMapper(EdiRuntime.current().transforms()).map(document, guide(prior, "ORIGINAL"), prior, rules(prior, null)).record();
|
||||
difference(state.previous, index, record(priorResult), actual);
|
||||
}
|
||||
}
|
||||
return (findings);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable record values and recursive associations, without QQQ diagnostics.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> record(QRecord record)
|
||||
{
|
||||
Map<String, Object> associations = new LinkedHashMap<>();
|
||||
if(record.getAssociatedRecords() != null)
|
||||
{
|
||||
record.getAssociatedRecords().forEach((name, children) -> associations.put(name, children.stream().map(MapPreviewService::record).toList()));
|
||||
}
|
||||
return (object("values", record.getValues(), "associations", associations));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare normalized JSON so temporal values and numeric types match wire output.
|
||||
*******************************************************************************/
|
||||
private static void difference(List<Map<String, Object>> result, int index, Object before, Object after)
|
||||
{
|
||||
if(!JSON.valueToTree(before).equals(JSON.valueToTree(after)))
|
||||
{
|
||||
result.add(object("documentIndex", index, "before", before, "after", after));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered objects allowing explicit nulls on the wire.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> object(Object... pairs)
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for(int i = 0; i < pairs.length; i += 2)
|
||||
{
|
||||
result.put((String) pairs[i], pairs[i + 1]);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One invocation's accumulation; never shared across requests.
|
||||
*******************************************************************************/
|
||||
private static class State
|
||||
{
|
||||
private int interchanges;
|
||||
private int groups;
|
||||
private int documents;
|
||||
private final List<Map<String, Object>> findings = new ArrayList<>();
|
||||
private final List<Map<String, Object>> records = new ArrayList<>();
|
||||
private final List<Map<String, Object>> previous = new ArrayList<>();
|
||||
private final List<Map<String, Object>> expected = new ArrayList<>();
|
||||
private String ackType = "NONE";
|
||||
private final StringBuilder ackText = new StringBuilder();
|
||||
|
||||
/*******************************************************************************
|
||||
** Envelope diagnostics carry no raw envelope identifiers.
|
||||
*******************************************************************************/
|
||||
private void envelope(List<EnvelopeFinding> values)
|
||||
{
|
||||
values.forEach(f -> findings.add(object("snipLevel", 1, "severity", "ERROR", "segmentPosition", f.segmentPosition(),
|
||||
"segmentId", null, "loopId", null, "elementPosition", null, "code", f.code(), "message", "Envelope validation finding")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate acknowledgements in wire order.
|
||||
*******************************************************************************/
|
||||
private void ack(String type, String text)
|
||||
{
|
||||
ackType = "NONE".equals(ackType) || type.equals(ackType) ? type : "MIXED";
|
||||
if(text != null)
|
||||
{
|
||||
ackText.append(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.guide.Guide;
|
||||
import com.kingsrook.qbits.edi.guide.GuideSegment;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Static validation of unsaved rules; never runs transforms or writes records.
|
||||
*******************************************************************************/
|
||||
public class MapRuleValidator
|
||||
{
|
||||
private static final Pattern ELEMENT = Pattern.compile("([A-Z][A-Z0-9]{1,2}?)([0-9]{2})(?:-([0-9]+))?");
|
||||
private static final Pattern STEP = Pattern.compile("([^\\[]+)(?:\\[(\\*|[1-9][0-9]*|[^\\[\\]]+=[^\\[\\]]+)])?");
|
||||
|
||||
/*******************************************************************************
|
||||
** Report independent errors without echoing rule literals or default values.
|
||||
*******************************************************************************/
|
||||
public List<String> validate(QRecord rule, QRecord map, Guide guide, TransformRegistry registry)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(rule.getValueInteger("sequence") == null || rule.getValueInteger("sequence") < 1)
|
||||
{
|
||||
errors.add("sequence must be a positive integer");
|
||||
}
|
||||
boolean outbound = "OUTBOUND".equals(map.getValueString("direction"));
|
||||
String ediPath = rule.getValueString("ediPath");
|
||||
String transform = Objects.requireNonNullElse(rule.getValueString("transform"), "").toUpperCase(Locale.ROOT);
|
||||
if(ediPath == null || ediPath.isBlank())
|
||||
{
|
||||
if(outbound || !transform.equals("CONSTANT") && rule.getValueString("defaultValue") == null)
|
||||
{
|
||||
errors.add("ediPath is required");
|
||||
}
|
||||
}
|
||||
else if(!resolves(ediPath, guide))
|
||||
{
|
||||
errors.add("ediPath does not resolve in the guide");
|
||||
}
|
||||
String target = rule.getValueString("qqqPath");
|
||||
QTableMetaData table = QContext.getQInstance().getTable(map.getValueString("tableName"));
|
||||
String[] fields = target == null ? new String[0] : target.split("\\.", -1);
|
||||
for(int i = 0; table != null && i < fields.length - 1; i++)
|
||||
{
|
||||
var associations = table.getAssociations();
|
||||
String name = fields[i];
|
||||
var association = associations == null ? null : associations.stream().filter(a -> a.getName().equals(name)).findFirst().orElse(null);
|
||||
table = association == null ? null : QContext.getQInstance().getTable(association.getAssociatedTableName());
|
||||
}
|
||||
boolean generated = outbound && (target == null || target.isBlank()) && List.of("CONSTANT", "SEQUENCE", "HL").contains(transform);
|
||||
if(!generated && (table == null || fields.length == 0 || !table.getFields().containsKey(fields[fields.length - 1])
|
||||
|| !outbound && fields.length > 2))
|
||||
{
|
||||
errors.add("qqqPath does not resolve in the target table");
|
||||
}
|
||||
if(fields.length > 1 && !"OUTBOUND".equals(map.getValueString("direction")) && (ediPath == null || !ediPath.contains("[*]/")))
|
||||
{
|
||||
errors.add("An inbound association requires a wildcard loop");
|
||||
}
|
||||
Map<String, Object> schema = registry.argumentSchemas().get(transform);
|
||||
if(!transform.isEmpty() && schema == null)
|
||||
{
|
||||
errors.add("Unknown transform");
|
||||
}
|
||||
try
|
||||
{
|
||||
String raw = rule.getValueString("transformArgs");
|
||||
var args = new ObjectMapper().readTree(raw == null || raw.isBlank() ? "{}" : raw);
|
||||
if(!args.isObject() || java.util.stream.StreamSupport.stream(java.util.Spliterators.spliteratorUnknownSize(args.elements(), 0), false).anyMatch(v -> !v.isTextual()))
|
||||
{
|
||||
errors.add("transformArgs must be a JSON object of strings");
|
||||
}
|
||||
else if(schema != null && schema.get("required") instanceof List<?> required)
|
||||
{
|
||||
for(Object key : required)
|
||||
{
|
||||
if(!args.hasNonNull(key.toString()))
|
||||
{
|
||||
errors.add("Missing required transform argument: " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
errors.add("transformArgs must be a JSON object of strings");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve every joined element path, including selectors and composite positions.
|
||||
*******************************************************************************/
|
||||
private boolean resolves(String path, Guide guide)
|
||||
{
|
||||
for(String part : path.split("\\+(?![^\\[]*])", -1))
|
||||
{
|
||||
List<GuideSegment> scope = guide.segments();
|
||||
String[] steps = part.trim().split("/", -1);
|
||||
for(int index = 0; index < steps.length; index++)
|
||||
{
|
||||
var step = STEP.matcher(steps[index]);
|
||||
if(!step.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String name = step.group(1);
|
||||
String qualifier = step.group(2);
|
||||
var element = ELEMENT.matcher(name);
|
||||
if(index == steps.length - 1)
|
||||
{
|
||||
if(!element.matches() || !element(scope, name))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
scope = scope.stream().filter(s -> s.segmentId().equals(element.group(1))).toList();
|
||||
}
|
||||
else
|
||||
{
|
||||
List<GuideSegment> current = scope;
|
||||
scope = current.stream().filter(s -> s.segmentId().equals(name) || inLoop(s, name, guide, 0)).toList();
|
||||
if(scope.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
if(qualifier != null && qualifier.contains("=") && !element(scope, qualifier.substring(0, qualifier.indexOf('='))))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ancestor scope matters when different loops contain the same segment.
|
||||
*******************************************************************************/
|
||||
private boolean inLoop(GuideSegment segment, String loop, Guide guide, int depth)
|
||||
{
|
||||
if(loop.equals(segment.loopId()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(segment.parentLoopId() == null || depth > guide.segments().size())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (loop.equals(segment.parentLoopId()) || guide.segments().stream().filter(s -> segment.parentLoopId().equals(s.loopId()))
|
||||
.anyMatch(parent -> inLoop(parent, loop, guide, depth + 1)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match an element and its optional composite component.
|
||||
*******************************************************************************/
|
||||
private boolean element(List<GuideSegment> segments, String path)
|
||||
{
|
||||
var match = ELEMENT.matcher(path);
|
||||
if(!match.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int position = Integer.parseInt(match.group(2));
|
||||
Integer component;
|
||||
try
|
||||
{
|
||||
component = match.group(3) == null ? null : Integer.valueOf(match.group(3));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (segments.stream().filter(s -> s.segmentId().equals(match.group(1))).flatMap(s -> s.elements().stream())
|
||||
.anyMatch(e -> e.position() == position && (component == null || component.equals(e.componentPosition()))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundled sample maps (synthetic, one per built-in document and version)
|
||||
** and their insert-only installation. Installed maps are drafts, bound to no
|
||||
** partner and to no agreement document, so installing one never changes how
|
||||
** any document is processed; the target table in each is an example name the
|
||||
** host replaces before activating.
|
||||
*******************************************************************************/
|
||||
public final class SampleMaps
|
||||
{
|
||||
public static final List<String> RESOURCES = List.of("204-004010", "204-005010", "204-out-004010", "204-out-005010", "214-004010", "214-005010",
|
||||
"810-004010", "810-005010", "846-004010", "846-005010", "850-004010", "850-005010", "855-004010", "855-005010", "856-004010", "856-005010",
|
||||
"860-004010", "860-005010", "940-004010", "940-005010", "945-004010", "945-005010", "945-in-004010", "945-in-005010",
|
||||
"DESADV-D01B", "DESADV-D96A", "INVOIC-D01B", "INVOIC-D96A", "ORDERS-D01B", "ORDERS-D96A", "ORDRSP-D01B", "ORDRSP-D96A");
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SampleMaps()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One bundled sample: the map's values and its rules.
|
||||
*******************************************************************************/
|
||||
public static JsonNode read(String resource) throws IOException
|
||||
{
|
||||
try(InputStream input = SampleMaps.class.getResourceAsStream("/edi/maps/" + resource + "-sample.json"))
|
||||
{
|
||||
if(input == null)
|
||||
{
|
||||
throw (new IOException("Unknown sample map " + resource));
|
||||
}
|
||||
return (new ObjectMapper().readTree(input));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundled samples whose map name does not exist yet (any revision, any
|
||||
** partner), in bundle order. Reads the map table as the processing user.
|
||||
*******************************************************************************/
|
||||
public static List<String> missing() throws QException, IOException
|
||||
{
|
||||
Set<String> existing = new HashSet<>();
|
||||
for(QRecord map : SystemWrites.query("edi_map", new QQueryFilter()))
|
||||
{
|
||||
existing.add(map.getValueString("name"));
|
||||
}
|
||||
List<String> missing = new ArrayList<>();
|
||||
for(String resource : RESOURCES)
|
||||
{
|
||||
if(!existing.contains(read(resource).path("map").path("name").asText()))
|
||||
{
|
||||
missing.add(resource);
|
||||
}
|
||||
}
|
||||
return (missing);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The map record a sample installs as: a draft, revision 0, no partner,
|
||||
** bound to the built-in guide for its document and version when present.
|
||||
*******************************************************************************/
|
||||
public static QRecord mapRecord(String resource) throws IOException, QException
|
||||
{
|
||||
JsonNode map = read(resource).path("map");
|
||||
QRecord record = new QRecord();
|
||||
map.fields().forEachRemaining(e ->
|
||||
{
|
||||
if(!e.getValue().isNull() && !List.of("status", "revision", "partnerId", "guideId", "parentMapId").contains(e.getKey()))
|
||||
{
|
||||
record.withValue(e.getKey(), e.getValue().asText());
|
||||
}
|
||||
});
|
||||
record.withValue("status", "DRAFT").withValue("revision", 0);
|
||||
Integer guideId = null;
|
||||
int revision = -1;
|
||||
for(QRecord guide : SystemWrites.query("edi_guide", new QQueryFilter().withCriteria("transactionSet", QCriteriaOperator.EQUALS, map.path("transactionSet").asText())
|
||||
.withCriteria("version", QCriteriaOperator.EQUALS, map.path("version").asText()).withCriteria("source", QCriteriaOperator.EQUALS, "BUILT_IN")
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")))
|
||||
{
|
||||
int candidate = guide.getValueInteger("revision") == null ? 0 : guide.getValueInteger("revision");
|
||||
if(candidate > revision)
|
||||
{
|
||||
revision = candidate;
|
||||
guideId = guide.getValueInteger("id");
|
||||
}
|
||||
}
|
||||
return (record.withValue("guideId", guideId));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert one sample and its ordered rules. Skips (returns null) a sample
|
||||
** whose map name now exists, so a repeated run adds nothing. It is an
|
||||
** administrator action; two people running it at the same moment is not
|
||||
** guarded against (edi_map has no unique name, since revisions share one).
|
||||
*******************************************************************************/
|
||||
public static Integer install(String resource) throws Exception
|
||||
{
|
||||
JsonNode sample = read(resource);
|
||||
String name = sample.path("map").path("name").asText();
|
||||
if(!EdiRecords.query("edi_map", EdiRecords.equal("name", name)).isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord map = SystemWrites.insert("edi_map", mapRecord(resource));
|
||||
for(JsonNode rule : sample.path("rules"))
|
||||
{
|
||||
QRecord row = new QRecord();
|
||||
rule.fields().forEachRemaining(e ->
|
||||
{
|
||||
if(!e.getValue().isNull())
|
||||
{
|
||||
row.withValue(e.getKey(), e.getValue().isInt() ? (java.io.Serializable) e.getValue().intValue() : e.getValue().isBoolean() ? (java.io.Serializable) e.getValue().booleanValue() : e.getValue().asText());
|
||||
}
|
||||
});
|
||||
SystemWrites.insert("edi_map_rule", row.withValue("mapId", map.getValue("id")));
|
||||
}
|
||||
return (map.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
public class TransformRegistry
|
||||
{
|
||||
private final Map<String, Transform> transforms = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, Object>> argumentSchemas = new ConcurrentHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform a selected path's values in the service session.
|
||||
@@ -118,11 +119,59 @@ public class TransformRegistry
|
||||
** Register or replace a named host transform.
|
||||
*******************************************************************************/
|
||||
public TransformRegistry register(String name, Transform transform)
|
||||
{
|
||||
return (register(name, transform, schema(name)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host transforms publish their argument contract alongside executable behavior.
|
||||
*******************************************************************************/
|
||||
public TransformRegistry register(String name, Transform transform, Map<String, Object> argumentSchema)
|
||||
{
|
||||
transforms.put(name.toUpperCase(Locale.ROOT), transform);
|
||||
argumentSchemas.put(name.toUpperCase(Locale.ROOT), Map.copyOf(argumentSchema));
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sorted snapshot for mapper clients and static validation.
|
||||
*******************************************************************************/
|
||||
public Map<String, Map<String, Object>> argumentSchemas()
|
||||
{
|
||||
return (java.util.Collections.unmodifiableMap(new java.util.TreeMap<>(argumentSchemas)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Built-ins accept strings, matching the persisted transformArgs representation.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> schema(String name)
|
||||
{
|
||||
List<String> keys = switch(name.toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "CONSTANT" -> List.of("value");
|
||||
case "LOOKUP" -> List.of("table", "key", "result");
|
||||
case "DATETIME" -> List.of("time", "timezone");
|
||||
case "DATEPART", "TIMEPART" -> List.of("timezone");
|
||||
case "DECIMAL" -> List.of("scale");
|
||||
case "CONCAT" -> List.of("separator");
|
||||
case "SPLIT" -> List.of("separator", "index");
|
||||
case "SUBSTRING" -> List.of("start", "end");
|
||||
case "UNIT" -> List.of("factor", "divisor", "scale");
|
||||
case "SEQUENCE" -> List.of("start");
|
||||
case "HL" -> List.of("code");
|
||||
default -> List.of();
|
||||
};
|
||||
Map<String, Object> properties = new java.util.TreeMap<>();
|
||||
keys.forEach(key -> properties.put(key, Map.of("type", "string")));
|
||||
List<String> required = switch(name.toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "CONSTANT" -> List.of("value");
|
||||
case "LOOKUP" -> List.of("table", "key");
|
||||
default -> List.of();
|
||||
};
|
||||
return (Map.of("type", "object", "properties", properties, "required", required, "additionalProperties", Map.of("type", "string")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute a closed registry entry; unknown names fail explicitly.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -27,7 +27,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 form filters; no dependency on later dependency-behavior metadata.
|
||||
** RC.6 form filters; no dependency on later dependency-behavior metadata.
|
||||
*******************************************************************************/
|
||||
public final class EdiContingencies
|
||||
{
|
||||
@@ -75,7 +75,9 @@ public final class EdiContingencies
|
||||
}
|
||||
if(filter != null)
|
||||
{
|
||||
field.withPossibleValueSourceFilter(filter);
|
||||
// Core appends its search criteria to the root; keep them ANDed with partner scope.
|
||||
field.withPossibleValueSourceFilter(filter.getBooleanOperator() == QQueryFilter.BooleanOperator.OR
|
||||
? new QQueryFilter().withSubFilter(filter) : filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.metadata;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-edi.yaml (the QQQ help-bundle format) to the Edi app
|
||||
** and its edi_* tables: what each table is for and how its rows get created,
|
||||
** shown on the table screen and when it is empty.
|
||||
** Fallback for hosts without the helpContent table. Hosts with that table use
|
||||
** the core bundle registry and startup sync, including HOST and USER overrides.
|
||||
*******************************************************************************/
|
||||
public final class EdiHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-edi.yaml";
|
||||
public static final String SLOT = "help";
|
||||
public static final String APP_KEY = "edi";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EdiHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
for(var entry : HelpBundles.load("help/qbit-edi.yaml", EdiHelp.class.getClassLoader()).getEntries())
|
||||
{
|
||||
entries.put(entry.getKey(), mapper.valueToTree(entry));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app (by its configured name) and every edi_* table. A
|
||||
** host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, String appName) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(appName);
|
||||
JsonNode appEntry = entries.get("app:" + APP_KEY + ";slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the tooltip summary separately from the Markdown long help.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withSummary(entry.path("summary").asText().strip()).withContentAsMarkdown(entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -51,10 +51,10 @@ import com.kingsrook.qbits.edi.model.EdiPayload;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransaction;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransactionError;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.security.EdiClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -234,9 +234,10 @@ public final class EdiTables
|
||||
.withPermissionRules(new QPermissionRules().withPermissionBaseName("edi." + name.substring(4)).withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(readOnly || name.equals("edi_map_sample") ? AuditLevel.RECORD : AuditLevel.FIELD));
|
||||
QRecordEntity.registerTable(entity, table);
|
||||
AiTableMetaData.ofOrWithNew(table).withClassification(DataClassification.CONFIDENTIAL);
|
||||
if(List.of("edi_payload", "edi_certificate", "edi_inbox").contains(name))
|
||||
{
|
||||
table.withSupplementalMetaData(new AiTableMetaData().withExposure(AiExposure.NONE));
|
||||
AiTableMetaData.ofOrWithNew(table).withExposure(AiExposure.NONE);
|
||||
}
|
||||
if(name.equals("edi_exception"))
|
||||
{
|
||||
@@ -253,13 +254,14 @@ public final class EdiTables
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? "PHI"
|
||||
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? "PII" : "CONFIDENTIAL";
|
||||
field.withSupplementalMetaData(new EdiClassifications(classification));
|
||||
if(classification.equals("PHI"))
|
||||
DataClassification classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? DataClassification.PHI
|
||||
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? DataClassification.PII : DataClassification.CONFIDENTIAL;
|
||||
AiFieldMetaData metadata = new AiFieldMetaData().withClassification(classification);
|
||||
if(classification == DataClassification.PHI)
|
||||
{
|
||||
field.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
|
||||
metadata.withExposure(AiExposure.NONE);
|
||||
}
|
||||
field.withSupplementalMetaData(metadata);
|
||||
}
|
||||
EdiTablePresentation.apply(table);
|
||||
EdiRecordLabels.configure(table);
|
||||
|
||||
@@ -32,6 +32,9 @@ import com.kingsrook.qbits.edi.map.MapPreview;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
@@ -85,6 +88,7 @@ public final class EdiProcesses
|
||||
result.add(action("edi_guide", "override", "Create Partner Override", "edi.guide.override"));
|
||||
result.add(action("edi_guide", "activate", "Activate Guide", "edi.guide.activate"));
|
||||
result.add(action("edi_map", "activate", "Activate Map", "edi.map.activate"));
|
||||
result.add(InstallSampleMapsProcess.process());
|
||||
QProcessMetaData scheduled = new QProcessMetaData().withName("edi_inbox_worker").withLabel("Process Inbox").withIsHidden(true)
|
||||
.withPermissionRules(permission("edi.admin")).withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(WorkerStep.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
@@ -134,6 +138,8 @@ public final class EdiProcesses
|
||||
.withName(table + "_" + operation).withLabel(label).withTableName(table).withIsHidden(true).withMinInputRecords(1).withPermissionRules(permission(permission));
|
||||
if(operation.equals("preview"))
|
||||
{
|
||||
process.getPermissionRules().withCustomPermissionChecker(
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker.build("edi.map.preview"));
|
||||
if(table.equals("edi_map"))
|
||||
{
|
||||
process.getFrontendStep("review").withFormField(new QFieldMetaData("sampleId", QFieldType.INTEGER)
|
||||
@@ -316,13 +322,25 @@ public final class EdiProcesses
|
||||
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("status", operation.equals("assign") ? "IN_PROGRESS" : operation.equals("ignore") ? "IGNORED" : "RESOLVED");
|
||||
if(operation.equals("assign"))
|
||||
{
|
||||
update.withValue("assignedToUserId", input.getValue("assignedToUserId"));
|
||||
String assignee = input.getValueString("assignedToUserId");
|
||||
if(assignee == null || assignee.isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("An assignee is required");
|
||||
}
|
||||
update.withValue("assignedToUserId", assignee);
|
||||
}
|
||||
else
|
||||
{
|
||||
update.withValue("resolvedBy", user).withValue("resolvedAt", EdiRuntime.current().config().getClock().instant());
|
||||
}
|
||||
SystemWrites.update(table, update);
|
||||
if(operation.equals("assign"))
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("EDI exception assigned")
|
||||
.withBody("An EDI exception has been assigned to you.")
|
||||
.withAudience(NotificationAudience.user(update.getValueString("assignedToUserId")))
|
||||
.withSourceName("edi").withSourceTableName(table).withSourceRecordId(row.getValueString("id")));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.processes;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.map.SampleMaps;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** "Install sample maps": a table action on edi_map that adds the bundled
|
||||
** synthetic sample maps whose name does not exist yet. Standard streamed ETL:
|
||||
** Review lists what will be added, Result what was added. Insert-only by map
|
||||
** name, so existing maps and edits are never touched. Installed maps are
|
||||
** drafts with no partner and no agreement document, so nothing changes how a
|
||||
** document is processed until an administrator activates and binds one.
|
||||
*******************************************************************************/
|
||||
public final class InstallSampleMapsProcess
|
||||
{
|
||||
public static final String NAME = "edi_map_installSamples";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private InstallSampleMapsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action (no records need to be selected).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, "edi_map");
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, "edi_map");
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"These sample maps will be added as drafts, with no partner and no agreement document. Maps that already exist are not changed.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Install sample maps");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName("edi_map");
|
||||
process.setIsHidden(true);
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(new QPermissionRules().withPermissionBaseName("edi.admin").withLevel(PermissionLevel.HAS_ACCESS_PERMISSION));
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The samples still to install, as the processing user.
|
||||
*******************************************************************************/
|
||||
static List<String> missing() throws QException
|
||||
{
|
||||
return (SystemWrites.call(SampleMaps::missing));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with the standard four wordings.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String futurePlural, String past, String pastPlural)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(future);
|
||||
line.setPluralFutureMessage(futurePlural);
|
||||
line.setSingularPastMessage(past);
|
||||
line.setPluralPastMessage(pastPlural);
|
||||
return (line);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the missing samples, computed (no query of selected records).
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** How many samples will be added.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (missing().size());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One record per missing sample.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(String resource : missing())
|
||||
{
|
||||
QRecord map = SystemWrites.call(() -> SampleMaps.mapRecord(resource));
|
||||
getRecordPipe().addRecord(map.withValue("sample", resource));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform: forward the records and count them by standard and direction.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> byKind = new LinkedHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Count by standard and direction.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String kind = record.getValueString("standard") + " " + record.getValueString("direction").toLowerCase();
|
||||
byKind.computeIfAbsent(kind, k -> line(Status.OK, k + " sample map will be added as a draft", k + " sample maps will be added as drafts",
|
||||
k + " sample map was added as a draft", k + " sample maps were added as drafts")).incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Review summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
byKind.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every sample map is already installed."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert each map and its rules as the processing user.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine added = line(Status.OK, "map will be added", "maps will be added", "map was added as a draft", "maps were added as drafts");
|
||||
|
||||
/*******************************************************************************
|
||||
** One transaction per map, so a map never lands without its rules.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
Integer id = SystemWrites.call(() -> SystemWrites.transaction(() -> SampleMaps.install(record.getValueString("sample"))));
|
||||
if(id != null)
|
||||
{
|
||||
added.incrementCountAndAddPrimaryKey(id);
|
||||
output.getRecords().add(new QRecord().withValue("id", id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Result summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
added.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,11 @@ public class OutboundProcesses implements BackendStep
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(OutboundProcesses.class)));
|
||||
if(config.getSchedulerName() != null && (seconds != null || cron != null))
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withCronExpression(cron).withCronTimeZoneId("UTC"));
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// core validation refuses a cron time zone on a repeat schedule //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withCronExpression(cron)
|
||||
.withCronTimeZoneId(cron == null ? null : "UTC"));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.security;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6). Field classifications on RC.5.
|
||||
*******************************************************************************/
|
||||
public class EdiClassifications extends QSupplementalFieldMetaData
|
||||
{
|
||||
private final String classification;
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain classification as metadata until the core enum is available.
|
||||
*******************************************************************************/
|
||||
public EdiClassifications(String classification)
|
||||
{
|
||||
this.classification = classification;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable supplemental metadata key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("edi.classification");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the classification without requiring an RC.6 class.
|
||||
*******************************************************************************/
|
||||
public String getClassification()
|
||||
{
|
||||
return (classification);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bridge when a host supplies the newer field classification API.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance instance, QFieldMetaData field)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.DataClassification");
|
||||
Object value = type.getField(classification).get(null);
|
||||
field.getClass().getMethod("setDataClassification", type).invoke(field, value);
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
// RC.5 keeps the explicit supplemental classification below.
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw new IllegalStateException("Update the EDI classification bridge for this core version", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,8 @@ import org.apache.logging.log4j.message.Message;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6).
|
||||
** RC.5 logs query criteria verbatim. Suppress synchronous diagnostic logging only
|
||||
** STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7).
|
||||
** RC.6 logs query criteria verbatim. Suppress synchronous diagnostic logging only
|
||||
** while PHI mapping/application runs; value-free EDI events remain in the database.
|
||||
** This does not replace host masking or protection of host code outside this scope.
|
||||
*******************************************************************************/
|
||||
@@ -60,7 +60,7 @@ public final class PhiLogScope implements AutoCloseable
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a scope around code that may pass clinical values to RC.5 core actions.
|
||||
** Open a scope around code that may pass clinical values to RC.6 core actions.
|
||||
*******************************************************************************/
|
||||
public static PhiLogScope open(boolean enabled)
|
||||
{
|
||||
@@ -74,7 +74,7 @@ public final class PhiLogScope implements AutoCloseable
|
||||
{
|
||||
if(!(LogManager.getContext(false) instanceof LoggerContext context))
|
||||
{
|
||||
throw new IllegalStateException("PHI processing requires the RC.5 Log4j protection bridge");
|
||||
throw new IllegalStateException("PHI processing requires the RC.6 Log4j protection bridge");
|
||||
}
|
||||
Configuration configuration = context.getConfiguration();
|
||||
if(!INSTALLED.containsKey(configuration))
|
||||
|
||||
@@ -60,6 +60,17 @@ public final class EdiRecords
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(com.kingsrook.qqq.backend.core.context.QContext.getQBackendTransaction())).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** UI-facing reads enforce permissions even when the host disables default enforcement.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> queryEnforced(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true)
|
||||
.withTransaction(com.kingsrook.qqq.backend.core.context.QContext.getQBackendTransaction());
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match one field by exact value.
|
||||
*******************************************************************************/
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,792 @@
|
||||
components:
|
||||
schemas:
|
||||
BasicErrorResponseV1:
|
||||
description: "The error body of every UI API operation (core and extensions)."
|
||||
properties:
|
||||
error:
|
||||
description: "The error message (the same as message; kept for clients of\
|
||||
\ UI API 1.0 to 1.5)."
|
||||
type: "string"
|
||||
code:
|
||||
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
|
||||
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
|
||||
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
|
||||
\ Extensions may add codes; treat an unknown code by the HTTP status.\
|
||||
\ Since 1.6.0."
|
||||
type: "string"
|
||||
message:
|
||||
description: "A message to show the user. Since 1.6.0."
|
||||
type: "string"
|
||||
fieldErrors:
|
||||
description: "Errors about particular request fields, when known. Since\
|
||||
\ 1.6.0."
|
||||
items:
|
||||
properties:
|
||||
field:
|
||||
description: "The field (or parameter) name."
|
||||
type: "string"
|
||||
message:
|
||||
description: "The error about that field."
|
||||
type: "string"
|
||||
code:
|
||||
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
|
||||
\ PATTERN."
|
||||
type: "string"
|
||||
type: "object"
|
||||
type: "array"
|
||||
traceId:
|
||||
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
|
||||
\ response header); quote it when reporting a problem. Since 1.6.0."
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiAckV1:
|
||||
description: "Concatenated acknowledgement previews; type is 997, 999, TA1,\
|
||||
\ CONTRL, NONE or MIXED. text is null for PHI."
|
||||
properties:
|
||||
text:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiAssociationV1:
|
||||
description: "Direct QQQ association and its target fields."
|
||||
properties:
|
||||
fields:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiFieldV1"
|
||||
type: "array"
|
||||
label:
|
||||
nullable: true
|
||||
type: "string"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
table:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiDiffV1:
|
||||
description: "Only differing records are returned. Both lists are empty for\
|
||||
\ PHI."
|
||||
properties:
|
||||
againstExpected:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiDifferenceV1"
|
||||
type: "array"
|
||||
againstPreviousRevision:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiDifferenceV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiDifferenceV1:
|
||||
description: "One document comparison; documentIndex is zero based."
|
||||
properties:
|
||||
after:
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
type: "object"
|
||||
before:
|
||||
description: "Expected JSON or a previous mapped record."
|
||||
nullable: true
|
||||
documentIndex:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiElementV1:
|
||||
description: "Element or composite component; codes contain at most 200 active\
|
||||
\ codes in lexical order."
|
||||
properties:
|
||||
codeListId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
codes:
|
||||
items:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "array"
|
||||
component:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
id:
|
||||
nullable: true
|
||||
type: "string"
|
||||
maxLength:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
minLength:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
position:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type:
|
||||
nullable: true
|
||||
type: "string"
|
||||
usage:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiErrorV1:
|
||||
description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1."
|
||||
properties:
|
||||
error:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiFieldV1:
|
||||
description: "QQQ target field metadata."
|
||||
properties:
|
||||
label:
|
||||
nullable: true
|
||||
type: "string"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
possibleValueSourceName:
|
||||
nullable: true
|
||||
type: "string"
|
||||
required:
|
||||
type: "boolean"
|
||||
type:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiFindingV1:
|
||||
description: "Value-free diagnostic. PHI messages are generic; only positions\
|
||||
\ and codes are retained."
|
||||
properties:
|
||||
code:
|
||||
nullable: true
|
||||
type: "string"
|
||||
elementPosition:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
loopId:
|
||||
nullable: true
|
||||
type: "string"
|
||||
message:
|
||||
nullable: true
|
||||
type: "string"
|
||||
segmentId:
|
||||
nullable: true
|
||||
type: "string"
|
||||
segmentPosition:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
severity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
snipLevel:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiGuideTreeV1:
|
||||
description: "Ordered guide tree. Root segments are outside loops; nested loops\
|
||||
\ retain parent scope."
|
||||
properties:
|
||||
loops:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiLoopV1"
|
||||
type: "array"
|
||||
segments:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiSegmentV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiLoopV1:
|
||||
description: "Guide loop, nested within its parent; repeat is the maximum occurrence\
|
||||
\ count."
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: "string"
|
||||
loops:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiLoopV1"
|
||||
type: "array"
|
||||
repeat:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
segments:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiSegmentV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiParseV1:
|
||||
description: "Counts of wire interchanges, groups and documents."
|
||||
properties:
|
||||
documents:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
groups:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
interchanges:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiPreviewRequestV1:
|
||||
description: "mapId is required. Supply exactly one of sampleId or sampleBase64.\
|
||||
\ Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces\
|
||||
\ the whole rule set in memory. CURRENT (default) uses the newest active guide\
|
||||
\ in the pinned guide family; ORIGINAL uses the pinned guide."
|
||||
properties:
|
||||
guideRevision:
|
||||
enum:
|
||||
- "CURRENT"
|
||||
- "ORIGINAL"
|
||||
nullable: true
|
||||
type: "string"
|
||||
mapId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
ruleOverrides:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleV1"
|
||||
nullable: true
|
||||
type: "array"
|
||||
sampleBase64:
|
||||
format: "byte"
|
||||
maxLength: 6990508
|
||||
nullable: true
|
||||
type: "string"
|
||||
sampleId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
sampleName:
|
||||
maxLength: 255
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiPreviewV1:
|
||||
description: "Parse, validate, acknowledge and map without target writes. PHI\
|
||||
\ suppresses result, ack text, and both diffs."
|
||||
properties:
|
||||
ack:
|
||||
$ref: "#/components/schemas/EdiAckV1"
|
||||
diff:
|
||||
$ref: "#/components/schemas/EdiDiffV1"
|
||||
findings:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiFindingV1"
|
||||
type: "array"
|
||||
parse:
|
||||
$ref: "#/components/schemas/EdiParseV1"
|
||||
phiSuppressed:
|
||||
type: "boolean"
|
||||
result:
|
||||
$ref: "#/components/schemas/EdiResultV1"
|
||||
nullable: true
|
||||
type: "object"
|
||||
EdiRecordV1:
|
||||
description: "Field values and recursively associated records. Values use the\
|
||||
\ target table field types."
|
||||
properties:
|
||||
associations:
|
||||
additionalProperties:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRecordV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
values:
|
||||
additionalProperties: true
|
||||
type: "object"
|
||||
type: "object"
|
||||
EdiResultV1:
|
||||
description: "Mapped records in document order; no target writes."
|
||||
properties:
|
||||
records:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRecordV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiRuleErrorsV1:
|
||||
description: "Correlate errors using zero-based index, optional id and sequence."
|
||||
properties:
|
||||
errors:
|
||||
items:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "array"
|
||||
id:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
index:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
sequence:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
EdiRuleV1:
|
||||
description: "Map rule. id is optional for unsaved rules. transformArgs is a\
|
||||
\ JSON object encoded as a string, as in core table CRUD."
|
||||
properties:
|
||||
condition:
|
||||
nullable: true
|
||||
type: "string"
|
||||
defaultValue:
|
||||
nullable: true
|
||||
type: "string"
|
||||
ediPath:
|
||||
nullable: true
|
||||
type: "string"
|
||||
id:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
qqqPath:
|
||||
nullable: true
|
||||
type: "string"
|
||||
required:
|
||||
nullable: true
|
||||
type: "boolean"
|
||||
sequence:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
transform:
|
||||
nullable: true
|
||||
type: "string"
|
||||
transformArgs:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiSegmentV1:
|
||||
description: "Segment definition in guide sequence order."
|
||||
properties:
|
||||
elements:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiElementV1"
|
||||
type: "array"
|
||||
id:
|
||||
nullable: true
|
||||
type: "string"
|
||||
max:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
min:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
position:
|
||||
nullable: true
|
||||
type: "string"
|
||||
usage:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiTargetV1:
|
||||
description: "Target metadata visible to the caller, current rules and registered\
|
||||
\ transforms."
|
||||
properties:
|
||||
associations:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiAssociationV1"
|
||||
type: "array"
|
||||
fields:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiFieldV1"
|
||||
type: "array"
|
||||
rules:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleV1"
|
||||
type: "array"
|
||||
table:
|
||||
nullable: true
|
||||
type: "string"
|
||||
transforms:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiTransformV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiTransformV1:
|
||||
description: "Registered transform and JSON Schema describing its string-valued\
|
||||
\ arguments."
|
||||
properties:
|
||||
argumentSchema:
|
||||
additionalProperties: true
|
||||
description: "JSON Schema: type, properties, required and additionalProperties.\
|
||||
\ All transform arguments are strings."
|
||||
type: "object"
|
||||
name:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
EdiValidateRequestV1:
|
||||
description: "rules is required and may be empty; rules are never persisted."
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
EdiValidationV1:
|
||||
description: "One result per submitted rule, including rules with no errors."
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: "#/components/schemas/EdiRuleErrorsV1"
|
||||
type: "array"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
bearerAuth:
|
||||
bearerFormat: "JWT or session UUID"
|
||||
description: "`Authorization: Bearer <token>`, where the token is either\n-\
|
||||
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
|
||||
\ to the app's client,\n or to its native client (`values.nativeClientId`\
|
||||
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
|
||||
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
|
||||
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
|
||||
\ as signing in on the web. When it expires (401), refresh it at the provider\
|
||||
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
|
||||
\ which the server keeps refreshed."
|
||||
scheme: "bearer"
|
||||
type: "http"
|
||||
oauth2:
|
||||
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
|
||||
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
|
||||
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
|
||||
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
|
||||
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
|
||||
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
|
||||
\ token\nas `Authorization: Bearer`."
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: "/authorize"
|
||||
scopes:
|
||||
email: "Email address"
|
||||
offline_access: "A refresh token, to renew the access token without signing\
|
||||
\ in again"
|
||||
openid: "OpenID Connect sign-in"
|
||||
profile: "Name, username and groups (permissions come from the groups)"
|
||||
tokenUrl: "/token"
|
||||
type: "oauth2"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "EDI sandbox and mapper, using the signed-in user's permissions."
|
||||
title: "QQQ Middleware API extension: edi"
|
||||
version: "1.1.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/edi/guides/{guideId}/tree:
|
||||
get:
|
||||
description: "Read the guide tree. Uses the caller session and table read permissions.\
|
||||
\ Preview also requires edi.map.preview. Rule writes use core table CRUD."
|
||||
operationId: "getQbitEdiGuidesByGuideIdTree"
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
name: "guideId"
|
||||
required: true
|
||||
schema:
|
||||
type: "integer"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiGuideTreeV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Read the guide tree"
|
||||
tags:
|
||||
- "edi"
|
||||
/qqq/v1/qbit/edi/maps/{mapId}/target:
|
||||
get:
|
||||
description: "Read target metadata and map rules. Uses the caller session and\
|
||||
\ table read permissions. Preview also requires edi.map.preview. Rule writes\
|
||||
\ use core table CRUD."
|
||||
operationId: "getQbitEdiMapsByMapIdTarget"
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
name: "mapId"
|
||||
required: true
|
||||
schema:
|
||||
type: "integer"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiTargetV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Read target metadata and map rules"
|
||||
tags:
|
||||
- "edi"
|
||||
/qqq/v1/qbit/edi/sandbox/preview:
|
||||
post:
|
||||
description: "Preview an encrypted sample with optional unsaved rules. Uses\
|
||||
\ the caller session and table read permissions. Preview also requires edi.map.preview.\
|
||||
\ Rule writes use core table CRUD."
|
||||
operationId: "postQbitEdiSandboxPreview"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiPreviewRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiPreviewV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Preview an encrypted sample with optional unsaved rules"
|
||||
tags:
|
||||
- "edi"
|
||||
/qqq/v1/qbit/edi/maps/{mapId}/validate:
|
||||
post:
|
||||
description: "Validate unsaved map rules. Uses the caller session and table\
|
||||
\ read permissions. Preview also requires edi.map.preview. Rule writes use\
|
||||
\ core table CRUD."
|
||||
operationId: "postQbitEdiMapsByMapIdValidate"
|
||||
parameters:
|
||||
- description: "Positive record identifier."
|
||||
in: "path"
|
||||
name: "mapId"
|
||||
required: true
|
||||
schema:
|
||||
type: "integer"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiValidateRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid request or sample exceeds 5 MiB"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing or invisible record"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EdiValidationV1"
|
||||
description: "Successful Response"
|
||||
413:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Host HTTP request size limit exceeded"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Validate unsaved map rules"
|
||||
tags:
|
||||
- "edi"
|
||||
servers:
|
||||
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
|
||||
url: "/"
|
||||
tags:
|
||||
- description: "Operations of the edi extension."
|
||||
name: "edi"
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
@@ -39,7 +40,7 @@ import org.junit.jupiter.params.provider.CsvSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Contingent metadata and real RC.5 possible-value searches with form values.
|
||||
** Contingent metadata and real RC.6 possible-value searches with form values.
|
||||
*******************************************************************************/
|
||||
class ContingenciesTest
|
||||
{
|
||||
@@ -123,7 +124,7 @@ class ContingenciesTest
|
||||
{
|
||||
var field = instance.getTable(pair[0]).getField(pair[1]);
|
||||
QQueryFilter interpreted = field.getPossibleValueSourceFilter().clone();
|
||||
// RC.5 middleware interprets the field filter before calling the search action.
|
||||
// RC.6 middleware interprets the field filter before calling the search action.
|
||||
interpreted.interpretValues(Map.of("partnerId", 1));
|
||||
var result = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(field.getPossibleValueSourceName())
|
||||
.withDefaultQueryFilter(interpreted).withOtherValues(Map.of("partnerId", 1)).withSearchTerm("").withLimit(100));
|
||||
@@ -133,6 +134,41 @@ class ContingenciesTest
|
||||
assertThat(instance.getTable("edi_exception").getField("assignedToUserId").getPossibleValueSourceFilter()).isNull();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.6 lacks dependency behavior metadata; shared transports still work with no partner.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sharedTransportBeforeAndAfterPartnerSelection() throws Exception
|
||||
{
|
||||
var instance = H2Support.create();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
for(Integer partner : java.util.Arrays.asList(null, 1, 2))
|
||||
{
|
||||
SystemWrites.insert("edi_transport", new QRecord().withValue("name", "van-" + partner).withValue("partnerId", partner)
|
||||
.withValue("type", "GATEWAY").withValue("direction", "BOTH").withValue("gatewayEndpointName", "synthetic-outbox"));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
for(var values : List.of(Map.<String, java.io.Serializable>of(), Map.<String, java.io.Serializable>of("partnerId", 1)))
|
||||
{
|
||||
var field = instance.getTable("edi_agreement").getField("outboundTransportId");
|
||||
var filter = field.getPossibleValueSourceFilter().clone();
|
||||
filter.interpretValues(Map.of("input", values), PossibleValueSearchFilterUseCase.FORM);
|
||||
var result = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
|
||||
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter)
|
||||
.withOtherValues(values).withSearchTerm("").withLimit(100));
|
||||
assertThat(result.getResults()).extracting(value -> value.getId().toString()).contains("1").doesNotContain("3");
|
||||
assertThat(result.getResults()).hasSize(values.isEmpty() ? 1 : 2);
|
||||
filter = field.getPossibleValueSourceFilter().clone();
|
||||
filter.interpretValues(Map.of("input", values), PossibleValueSearchFilterUseCase.FORM);
|
||||
var unrelated = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
|
||||
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter)
|
||||
.withOtherValues(values).withSearchTerm("van-2").withLimit(100));
|
||||
assertThat(unrelated.getResults()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test fixture operation.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.edi.api.EdiApi;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Generated extension fragment is the exact versioned contract shipped to clients.
|
||||
*******************************************************************************/
|
||||
class EdiApiContractTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check the generated contract and compatibility against the committed baseline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
var version = new MiddlewareVersionV1();
|
||||
var extension = EdiApi.extension();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
Path path = Path.of("src/main/resources/openapi/qqq-v1/edi.yaml").toAbsolutePath();
|
||||
if(Boolean.getBoolean("edi.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, generated);
|
||||
}
|
||||
String committed = Files.readString(path);
|
||||
assertThat(UiApiContract.check(generated, committed, committed, 1,
|
||||
"mvnw.cmd -B -Dtest=EdiApiContractTest -Dedi.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(n -> (n.startsWith("Edi") && n.endsWith("V1")) || n.equals("BasicErrorResponseV1"));
|
||||
assertThat(model.getPaths()).hasSize(4);
|
||||
assertThat(model.getPaths().keySet()).allMatch(n -> n.startsWith("/qqq/v1/qbit/edi/"));
|
||||
assertThat(extension.getEffectivePathPrefix()).isEqualTo("/qbit/edi");
|
||||
assertThat(extension.getSemanticVersion()).isEqualTo("1.1.0");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer registers the extension and all metadata validates with core middleware.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
var instance = H2Support.create();
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).getExtensions()).hasSize(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.context.QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.api.EdiApiService;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP against strict-mode QQQ, real SQL, real encrypted samples and caller sessions.
|
||||
*******************************************************************************/
|
||||
class EdiApiHttpTest
|
||||
{
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private QApplicationJavalinServer server;
|
||||
private HttpClient client;
|
||||
private QInstance instance;
|
||||
private QRecord map;
|
||||
private QRecord sample;
|
||||
private String baseUrl;
|
||||
private String suffix;
|
||||
|
||||
/*******************************************************************************
|
||||
** The existing synthetic vertical-slice fixture supplies tables, guide and map.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void start() throws Exception
|
||||
{
|
||||
new InboundTest().setup("004010");
|
||||
instance = QContext.getQInstance();
|
||||
map = EdiRecords.query("edi_map", new QQueryFilter()).getFirst();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map", new QRecord().withValue("id", map.getValue("id")).withValue("partnerId", 1));
|
||||
SystemWrites.update("edi_guide", new QRecord().withValue("id", map.getValue("guideId")).withValue("partnerId", 1));
|
||||
return (null);
|
||||
});
|
||||
map.withValue("partnerId", 1);
|
||||
sample = saveSample(Synthetic850.generate(17, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT), map.getValueInteger("id"));
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(Sessions.class)));
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
baseUrl = "http://127.0.0.1:" + port + "/qqq/v1/qbit/edi";
|
||||
suffix = "-" + UUID.randomUUID();
|
||||
client = HttpClient.newHttpClient();
|
||||
server = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (instance);
|
||||
}
|
||||
}).withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
|
||||
.withJavalinConfigCustomizer(config -> config.http.maxRequestSize = 8 * 1024 * 1024);
|
||||
server.start();
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Release server globals and thread context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stop()
|
||||
{
|
||||
if(server != null)
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
if(client != null)
|
||||
{
|
||||
client.close();
|
||||
}
|
||||
QJavalinImplementation.setSessionCookieHttpOnly(false);
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordinary users receive selected reads; none receives raw-payload access.
|
||||
*******************************************************************************/
|
||||
public static class Sessions implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
String role = session.getUuid().split("-")[0];
|
||||
session.withUser(new QUser().withIdReference(role));
|
||||
session.withSecurityKeyValue("edi_partner", 1);
|
||||
if(!role.equals("denied"))
|
||||
{
|
||||
for(String table : instance.getTables().keySet())
|
||||
{
|
||||
if(!table.equals("edi_payload") && !table.equals("edi_preview_download")
|
||||
&& !(role.equals("noguide") && table.equals("edi_guide"))
|
||||
&& !(role.equals("norules") && table.equals("edi_map_rule"))
|
||||
&& !(role.equals("notarget") && table.equals("sample_order")))
|
||||
{
|
||||
String permission = table.startsWith("edi_") ? "edi." + table.substring(4) + ".read"
|
||||
: table.equals("sample_order") ? "sample.order.read" : "sample.line.read";
|
||||
session.withPermissions(permission);
|
||||
}
|
||||
}
|
||||
if(!role.equals("reader"))
|
||||
{
|
||||
session.withPermissions("edi.map.preview");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport helper retains the exact response for egress scans.
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> request(String method, String path, Object body, String role) throws Exception
|
||||
{
|
||||
String text = body == null ? "" : body instanceof String s ? s : JSON.writeValueAsString(body);
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(baseUrl + path)).header("Cookie", "sessionUUID=" + role + suffix)
|
||||
.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(text)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
if(response.statusCode() >= 400)
|
||||
{
|
||||
JsonNode error = JSON.readTree(response.body());
|
||||
for(String field : List.of("error", "code", "message", "traceId"))
|
||||
{
|
||||
assertThat(error.path(field).isTextual()).as(field + " in " + response.body()).isTrue();
|
||||
assertThat(error.path(field).asText()).isNotBlank();
|
||||
}
|
||||
}
|
||||
return (response);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert success before parsing the wire JSON.
|
||||
*******************************************************************************/
|
||||
private JsonNode ok(String method, String path, Object body) throws Exception
|
||||
{
|
||||
var response = request(method, path, body, "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
return (JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An authorized fixture writer archives synthetic bytes only.
|
||||
*******************************************************************************/
|
||||
private QRecord saveSample(String text, Integer mapId) throws Exception
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
var ref = EdiRuntime.current().payloads().put(PayloadKind.SAMPLE, InboundTest.bytes(text));
|
||||
return (EdiRecords.insert("edi_map_sample", new QRecord().withValue("name", "Synthetic HTTP sample").withValue("mapId", mapId).withValue("payloadRef", ref.value())));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared stored-sample request.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> preview()
|
||||
{
|
||||
return (Map.of("mapId", map.getValueInteger("id"), "sampleId", sample.getValueInteger("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Guide structure and target schema include associations, rules and argument contracts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void guideAndTarget() throws Exception
|
||||
{
|
||||
var tree = ok("GET", "/guides/" + map.getValue("guideId") + "/tree", null);
|
||||
assertThat(tree.get("loops")).isNotEmpty();
|
||||
assertThat(tree.toString()).contains("\"id\":\"BEG\"", "\"position\":\"0030\"", "codeListId", "minLength");
|
||||
var target = ok("GET", "/maps/" + map.getValue("id") + "/target", null);
|
||||
assertThat(target.get("table").asText()).isEqualTo("sample_order");
|
||||
assertThat(target.get("fields").toString()).contains("poNumber", "possibleValueSourceName");
|
||||
assertThat(target.get("associations").toString()).contains("lines", "sample_order_line", "quantity");
|
||||
assertThat(target.get("rules")).hasSize(5);
|
||||
assertThat(target.get("transforms").toString()).contains("LOOKUP", "argumentSchema", "required", "table", "key");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Each operation returns real 403 responses for denied table or action permissions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissions() throws Exception
|
||||
{
|
||||
String target = "/maps/" + map.getValue("id") + "/target";
|
||||
String validate = "/maps/" + map.getValue("id") + "/validate";
|
||||
assertThat(request("GET", "/guides/" + map.getValue("guideId") + "/tree", null, "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", target, null, "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", validate, Map.of("rules", List.of()), "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "denied").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "reader").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", target, null, "notarget").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", target, null, "norules").statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", "/guides/" + map.getValue("guideId") + "/tree", null, "noguide").statusCode()).isEqualTo(403);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview returns the mapped structure and ack while leaving target tables untouched.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void storedPreview() throws Exception
|
||||
{
|
||||
var result = ok("POST", "/sandbox/preview", preview());
|
||||
assertThat(result.at("/parse/interchanges").asInt()).isEqualTo(1);
|
||||
assertThat(result.at("/parse/groups").asInt()).isEqualTo(1);
|
||||
assertThat(result.at("/parse/documents").asInt()).isEqualTo(1);
|
||||
assertThat(result.at("/result/records/0/values/poNumber").asText()).isNotBlank();
|
||||
assertThat(result.at("/result/records/0/associations/lines")).hasSize(1);
|
||||
assertThat(result.at("/ack/type").asText()).isEqualTo("997");
|
||||
assertThat(result.at("/ack/text").asText()).contains("AK9");
|
||||
assertThat(result.get("phiSuppressed").asBoolean()).isFalse();
|
||||
assertThat(EdiRecords.query("sample_order", new QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsaved overrides replace rules in memory and custom transforms see the caller.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unsavedOverridesAndCallerSession() throws Exception
|
||||
{
|
||||
EdiRuntime.current().transforms().register("SESSION", (values, args) -> QContext.getQSession().getUser().getIdReference(),
|
||||
Map.of("type", "object", "properties", Map.of(), "required", List.of()));
|
||||
var rule = Map.of("sequence", 1, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "SESSION");
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(rule));
|
||||
var result = ok("POST", "/sandbox/preview", body);
|
||||
assertThat(result.at("/result/records/0/values/poNumber").asText()).isEqualTo("user");
|
||||
assertThat(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")))).hasSize(5);
|
||||
assertThat(ok("POST", "/sandbox/preview", preview()).at("/result/records/0/values/poNumber").asText()).isNotEqualTo("user");
|
||||
body.put("ruleOverrides", List.of());
|
||||
assertThat(ok("POST", "/sandbox/preview", body).at("/result/records/0/values").size()).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Base64 uploads become encrypted samples, with no plaintext in the payload table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void upload() throws Exception
|
||||
{
|
||||
String raw = Synthetic850.generate(18, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT);
|
||||
int count = EdiRecords.query("edi_map_sample", new QQueryFilter()).size();
|
||||
var result = ok("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleBase64",
|
||||
Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.US_ASCII)), "sampleName", "Uploaded sample"));
|
||||
assertThat(result.at("/parse/documents").asInt()).isEqualTo(1);
|
||||
var samples = EdiRecords.query("edi_map_sample", new QQueryFilter());
|
||||
assertThat(samples).hasSize(count + 1);
|
||||
var uploaded = samples.stream().filter(r -> "Uploaded sample".equals(r.getValueString("name"))).findFirst().orElseThrow();
|
||||
try(var stream = EdiRuntime.current().payloads().open(new PayloadRef(uploaded.getValueString("payloadRef"), null, 0)))
|
||||
{
|
||||
assertThat(new String(stream.readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo(raw);
|
||||
}
|
||||
for(var payload : EdiRecords.query("edi_payload", new QQueryFilter()))
|
||||
{
|
||||
assertThat(new String((byte[]) payload.getValue("ciphertext"), StandardCharsets.ISO_8859_1)).doesNotContain("SYNTH-1", raw);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject oversized decoded bytes as well as encoded lengths before persistence.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void uploadLimitAndBadRequests() throws Exception
|
||||
{
|
||||
int count = EdiRecords.query("edi_map_sample", new QQueryFilter()).size();
|
||||
for(int size : List.of(EdiApiService.MAX_SAMPLE_BYTES + 1, EdiApiService.MAX_SAMPLE_BYTES + 4))
|
||||
{
|
||||
var response = request("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleBase64",
|
||||
Base64.getEncoder().encodeToString(new byte[size])), "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(400);
|
||||
}
|
||||
for(Object body : List.of(Map.of("mapId", map.getValueInteger("id"), "sampleBase64", "!invalid!"),
|
||||
Map.of("mapId", map.getValueInteger("id")), Map.of("mapId", map.getValueInteger("id"), "sampleId", sample.getValueInteger("id"), "sampleBase64", "AAAA"),
|
||||
Map.of("mapId", map.getValueInteger("id"), "sampleId", sample.getValueInteger("id"), "guideRevision", "BAD"),
|
||||
"{\"mapId\":1.2}", "{\"mapId\":\"1\"}", "{\"mapId\":1,\"ruleOverrides\":[null]}", "[]", "null", "invalid"))
|
||||
{
|
||||
assertThat(request("POST", "/sandbox/preview", body, "user").statusCode()).isEqualTo(400);
|
||||
}
|
||||
assertThat(EdiRecords.query("edi_map_sample", new QQueryFilter())).hasSize(count);
|
||||
assertThat(request("GET", "/maps/invalid/target", null, "user").statusCode()).isEqualTo(400);
|
||||
assertThat(request("GET", "/guides/999999/tree", null, "user").statusCode()).isEqualTo(404);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** PHI is detected through hidden agreement data, and canaries never reach responses.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void phiSuppression() throws Exception
|
||||
{
|
||||
String canary = "CANARY_PRIVATE_VALUE";
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
var document = EdiRecords.query("edi_agreement_document", EdiRecords.equal("mapId", map.getValue("id"))).getFirst();
|
||||
SystemWrites.update("edi_agreement_document", new QRecord().withValue("id", document.getValue("id")).withValue("containsPhi", true));
|
||||
return (null);
|
||||
});
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(Map.of("sequence", 1, "qqqPath", "poNumber", "transform", "CONSTANT", "transformArgs", "{\"value\":\"" + canary + "\"}")));
|
||||
var response = request("POST", "/sandbox/preview", body, "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
assertThat(response.body()).doesNotContain(canary, "SYNTH-1", "poNumber");
|
||||
var json = JSON.readTree(response.body());
|
||||
assertThat(json.get("phiSuppressed").asBoolean()).isTrue();
|
||||
assertThat(json.get("result").isNull()).isTrue();
|
||||
assertThat(json.at("/ack/text").isNull()).isTrue();
|
||||
assertThat(json.at("/diff/againstExpected")).isEmpty();
|
||||
assertThat(json.at("/diff/againstPreviousRevision")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rule errors are independently correlated; valid paths and transforms succeed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validateRules() throws Exception
|
||||
{
|
||||
var rules = ok("GET", "/maps/" + map.getValue("id") + "/target", null).get("rules");
|
||||
var valid = ok("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", rules));
|
||||
assertThat(valid.get("rules")).hasSize(5);
|
||||
valid.get("rules").forEach(rule -> assertThat(rule.get("errors")).as(rule.toString()).isEmpty());
|
||||
var invalid = List.of(
|
||||
Map.of("sequence", 1, "ediPath", "BAD99", "qqqPath", "unknown", "transform", "NO_SUCH_TRANSFORM"),
|
||||
Map.of("sequence", 2, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "LOOKUP", "transformArgs", "{}"),
|
||||
Map.of("sequence", 3, "ediPath", "PO1[*]/PO199", "qqqPath", "lines.sku", "transformArgs", "[]"),
|
||||
Map.of("sequence", 4, "ediPath", "BEG03", "qqqPath", "lines.sku", "transformArgs", "{\"index\":1}"),
|
||||
Map.of("sequence", 0, "qqqPath", "poNumber"));
|
||||
var errors = ok("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", invalid));
|
||||
assertThat(errors.at("/rules/0/errors")).hasSize(3);
|
||||
assertThat(errors.at("/rules/1/errors").toString()).contains("table", "key");
|
||||
assertThat(errors.at("/rules/2/errors").toString()).contains("ediPath", "transformArgs");
|
||||
assertThat(errors.at("/rules/3/errors").toString()).contains("wildcard", "transformArgs");
|
||||
assertThat(errors.at("/rules/4/errors").toString()).contains("sequence", "ediPath");
|
||||
assertThat(request("POST", "/maps/" + map.getValue("id") + "/validate", Map.of(), "user").statusCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing samples must belong to the selected map, and partner locks apply first.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sampleAndPartnerIsolation() throws Exception
|
||||
{
|
||||
QRecord another = SystemWrites.call(() -> EdiRecords.insert("edi_map", new QRecord().withValue("name", "Other map").withValue("partnerId", 1)
|
||||
.withValue("guideId", map.getValue("guideId")).withValue("tableName", "sample_order").withValue("direction", "INBOUND").withValue("transactionSet", "850")));
|
||||
assertThat(request("POST", "/sandbox/preview", Map.of("mapId", another.getValueInteger("id"), "sampleId", sample.getValueInteger("id")), "user").statusCode()).isEqualTo(404);
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map", new QRecord().withValue("id", map.getValue("id")).withValue("partnerId", 2));
|
||||
return (null);
|
||||
});
|
||||
assertThat(request("GET", "/maps/" + map.getValue("id") + "/target", null, "user").statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "user").statusCode()).isEqualTo(404);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A runtime transform failure returns a safe diagnostic instead of an exception value.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void mappingFailureIsValueFree() throws Exception
|
||||
{
|
||||
EdiRuntime.current().transforms().register("FAIL", (values, args) ->
|
||||
{
|
||||
throw new IllegalArgumentException("SECRET_CANARY");
|
||||
});
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(Map.of("sequence", 1, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "FAIL")));
|
||||
var response = request("POST", "/sandbox/preview", body, "user");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
assertThat(response.body()).doesNotContain("SECRET_CANARY").contains("\"code\":\"MAP\"");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact decoded limit is accepted, including base64 expansion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void exactUploadLimit() throws Exception
|
||||
{
|
||||
String raw = Synthetic850.generate(19, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT);
|
||||
String padded = raw + " ".repeat(EdiApiService.MAX_SAMPLE_BYTES - raw.length());
|
||||
var result = ok("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleBase64",
|
||||
Base64.getEncoder().encodeToString(padded.getBytes(StandardCharsets.US_ASCII))));
|
||||
assertThat(result.at("/parse/documents").asInt()).isEqualTo(1);
|
||||
assertThat(EdiRecords.query("edi_payload", new QQueryFilter())).anyMatch(r -> r.getValueLong("sizeBytes") == EdiApiService.MAX_SAMPLE_BYTES);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Required LOOKUP permissions are not borrowed from the configured service session.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lookupCannotElevate() throws Exception
|
||||
{
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("ruleOverrides", List.of(Map.of("sequence", 1, "ediPath", "BEG03", "qqqPath", "poNumber", "transform", "LOOKUP",
|
||||
"transformArgs", "{\"table\":\"sample_order\",\"key\":\"poNumber\"}")));
|
||||
var response = request("POST", "/sandbox/preview", body, "notarget");
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(403);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "norules").statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", List.of()), "noguide").statusCode()).isEqualTo(403);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A later active guide changes CURRENT findings while ORIGINAL keeps the pinned guide.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void guideRevisionSelection() throws Exception
|
||||
{
|
||||
var repository = new com.kingsrook.qbits.edi.guide.GuideRepository();
|
||||
var guide = repository.load(map.getValueInteger("guideId"));
|
||||
var segments = new ArrayList<>(guide.segments());
|
||||
segments.add(segments.size() - 1, new com.kingsrook.qbits.edi.guide.GuideSegment("XYZ", null, null, 1, "REQUIRED", 1, 1, List.of(), List.of(), null));
|
||||
var changed = new com.kingsrook.qbits.edi.guide.Guide(guide.name(), guide.standard(), guide.version(), guide.transactionSet(), guide.functionalIdCode(), segments, guide.rules());
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
Integer id = repository.save(changed, "synthetic-http-revision", 2);
|
||||
SystemWrites.update("edi_guide", new QRecord().withValue("id", id).withValue("partnerId", 1));
|
||||
return (null);
|
||||
});
|
||||
var body = new java.util.LinkedHashMap<>(preview());
|
||||
body.put("guideRevision", "ORIGINAL");
|
||||
var original = ok("POST", "/sandbox/preview", body);
|
||||
body.put("guideRevision", "CURRENT");
|
||||
var current = ok("POST", "/sandbox/preview", body);
|
||||
assertThat(current.get("findings").size()).isGreaterThan(original.get("findings").size());
|
||||
assertThat(current.get("findings").toString()).contains("XYZ");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Previous-revision and expected diffs identify the changed document and values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void revisionAndExpectedDiffs() throws Exception
|
||||
{
|
||||
QRecord revised = SystemWrites.call(() ->
|
||||
{
|
||||
QRecord next = new QRecord().withValue("revision", 2).withValue("partnerId", 1).withValue("guideId", map.getValue("guideId"));
|
||||
for(String field : List.of("name", "direction", "standard", "version", "transactionSet", "tableName", "status"))
|
||||
{
|
||||
next.withValue(field, map.getValue(field));
|
||||
}
|
||||
return (EdiRecords.insert("edi_map", next));
|
||||
});
|
||||
var nextSample = saveSample(Synthetic850.generate(17, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT), revised.getValueInteger("id"));
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map_sample", new QRecord().withValue("id", nextSample.getValue("id")).withValue("expectedResultJson", "{\"values\":{\"poNumber\":\"EXPECTED\"},\"associations\":{}}"));
|
||||
return (null);
|
||||
});
|
||||
var result = ok("POST", "/sandbox/preview", Map.of("mapId", revised.getValueInteger("id"), "sampleId", nextSample.getValueInteger("id"),
|
||||
"ruleOverrides", List.of(Map.of("sequence", 1, "qqqPath", "poNumber", "transform", "CONSTANT", "transformArgs", "{\"value\":\"EDITED\"}"))));
|
||||
assertThat(result.at("/diff/againstPreviousRevision")).hasSize(1);
|
||||
assertThat(result.at("/diff/againstExpected")).hasSize(1);
|
||||
assertThat(result.at("/diff/againstPreviousRevision/0/after/values/poNumber").asText()).isEqualTo("EDITED");
|
||||
assertThat(result.at("/diff/againstExpected/0/before/values/poNumber").asText()).isEqualTo("EXPECTED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Large code lists are deterministically capped in the guide-tree projection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void codeListLimit() throws Exception
|
||||
{
|
||||
var definitions = EdiRecords.query("edi_guide_segment", EdiRecords.equal("guideId", map.getValue("guideId")));
|
||||
Integer codeListId = null;
|
||||
for(var definition : definitions)
|
||||
{
|
||||
for(var element : EdiRecords.query("edi_guide_element", EdiRecords.equal("guideSegmentId", definition.getValue("id"))))
|
||||
{
|
||||
if(element.getValueInteger("codeListId") != null)
|
||||
{
|
||||
codeListId = element.getValueInteger("codeListId");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(codeListId != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
final Integer listId = codeListId;
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
for(int i = 0; i < 205; i++)
|
||||
{
|
||||
EdiRecords.insert("edi_code_list_value", new QRecord().withValue("codeListId", listId).withValue("code", "SYNTH" + i).withValue("isActive", true));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
var tree = ok("GET", "/guides/" + map.getValue("guideId") + "/tree", null);
|
||||
assertThat(tree.findValues("codes")).allSatisfy(codes -> assertThat(codes.size()).isLessThanOrEqualTo(200));
|
||||
assertThat(tree.findValues("codes")).anyMatch(codes -> codes.size() == 200);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transaction and interchange envelope errors are represented in findings and acknowledgements.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void envelopeFindings() throws Exception
|
||||
{
|
||||
String raw = Synthetic850.generate(20, "004010", com.kingsrook.qbits.edi.x12.X12Separators.DEFAULT);
|
||||
var invalid = saveSample(raw.replace("SE*9*", "SE*99*").replace("IEA*1*", "IEA*2*"), map.getValueInteger("id"));
|
||||
var response = ok("POST", "/sandbox/preview", Map.of("mapId", map.getValueInteger("id"), "sampleId", invalid.getValueInteger("id")));
|
||||
assertThat(response.get("findings")).hasSizeGreaterThanOrEqualTo(2);
|
||||
assertThat(response.get("findings").toString()).contains("\"snipLevel\":1");
|
||||
assertThat(response.at("/ack/type").asText()).isEqualTo("MIXED");
|
||||
assertThat(response.at("/ack/text").asText()).contains("TA1*", "ISA*", "AK9*");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A caller cannot inspect another partner's guide via a visible map.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hiddenGuide() throws Exception
|
||||
{
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_guide", new QRecord().withValue("id", map.getValue("guideId")).withValue("partnerId", 2));
|
||||
return (null);
|
||||
});
|
||||
assertThat(request("GET", "/guides/" + map.getValue("guideId") + "/tree", null, "user").statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", "/sandbox/preview", preview(), "user").statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", "/maps/" + map.getValue("id") + "/validate", Map.of("rules", List.of()), "user").statusCode()).isEqualTo(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Coverage of the qbit's own help, independent of host integrations.
|
||||
*******************************************************************************/
|
||||
class EdiHelpBundleTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Every owned target resolves and has help, even without a help table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bundleIsComplete() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new EdiQBitProducer().withQBitConfig(new EdiQBitConfig().withBackendName("memory")).produce(instance);
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-edi");
|
||||
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(EdiQBitProducer.VERSION);
|
||||
});
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
HelpBundleAssertions.forInstance(instance)
|
||||
.bundles("help/qbit-edi.yaml")
|
||||
.owning(target -> target.name().equals("edi") || target.name().startsWith("edi_"))
|
||||
.assertComplete();
|
||||
var keys = HelpBundles.of(instance).getBundles().getFirst().getEntries().stream().map(entry -> entry.getKey()).toList();
|
||||
// Core's gate currently requires apps, tables, processes and fields; also cover our widgets and value sources.
|
||||
for(var target : HelpTargetCatalog.collect(instance))
|
||||
{
|
||||
if(target.name().startsWith("edi_") && java.util.List.of("widget", "possibleValueSource").contains(target.kind()))
|
||||
{
|
||||
assertThat(keys).as(target.key()).contains(target.key());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Help-enabled hosts use core sync, and a configured app name is respected.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registersForHelpEnabledHost() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("helpContent"));
|
||||
new EdiQBitProducer().withQBitConfig(new EdiQBitConfig().withBackendName("memory").withAppName("trading")).produce(instance);
|
||||
var entries = HelpBundles.of(instance).getBundles().getFirst().getEntries();
|
||||
assertThat(entries).anyMatch(entry -> "app:trading;slot:help".equals(entry.getKey()))
|
||||
.noneMatch(entry -> "app:edi;slot:help".equals(entry.getKey()));
|
||||
assertThat(instance.getTable("edi_partner").getHelpContent()).isNullOrEmpty();
|
||||
assertThat(instance.getApp("trading").getHelpContent()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.app.EdiHome;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI home is for business users: system widgets live in a separate list
|
||||
** for the host's Admin home, gated to the EDI administrator; a stalled inbound
|
||||
** flow shows as one business item; every widget is permission-protected.
|
||||
*******************************************************************************/
|
||||
class EdiHomeAudienceTest
|
||||
{
|
||||
private static final Instant RECEIVED = Instant.parse("2026-10-16T12:00:00Z");
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The app's widget list is the business list; system widgets stay defined,
|
||||
** are gated to edi.admin and hidden, and nothing is NOT_PROTECTED.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeWidgetListExcludesSystemWidgets() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
List<String> appWidgets = instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets();
|
||||
assertThat(appWidgets).containsExactlyElementsOf(EdiHome.HOME_WIDGET_NAMES).doesNotContainAnyElementsOf(EdiHome.ADMIN_WIDGET_NAMES);
|
||||
assertThat(EdiHome.ADMIN_WIDGET_NAMES).containsExactlyInAnyOrder("edi_home_system", "edi_home_dead", "edi_home_certificates", "edi_home_setup");
|
||||
assertThat(appWidgets.getFirst()).as("attention comes first").isEqualTo("edi_home_delayed");
|
||||
|
||||
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
|
||||
{
|
||||
QWidgetMetaData widget = (QWidgetMetaData) instance.getWidget(name);
|
||||
assertThat(widget).as(name).isNotNull();
|
||||
assertThat(widget.getPermissionRules().getPermissionBaseName()).as(name).isEqualTo("edi.admin");
|
||||
assertThat(widget.getGridColumns()).as(name + " is full width in its Admin placement").isEqualTo(12);
|
||||
}
|
||||
for(String name : EdiHome.HOME_WIDGET_NAMES)
|
||||
{
|
||||
assertThat(((QWidgetMetaData) instance.getWidget(name)).getPermissionRules().getPermissionBaseName()).as(name).isEqualTo("edi");
|
||||
}
|
||||
instance.getWidgets().values().stream().filter(w -> w.getName().startsWith("edi_home_")).forEach(widget ->
|
||||
{
|
||||
assertThat(widget.getPermissionRules().getLevel()).as(widget.getName()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
assertThat(widget.getPermissionRules().getDenyBehavior()).as(widget.getName()).isEqualTo(DenyBehavior.HIDDEN);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A business EDI user (edi.hasAccess, no edi.admin) gets no system widget, in
|
||||
** metadata or by rendering; an EDI administrator gets them; a user without
|
||||
** edi.hasAccess gets no home widget at all.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void businessUserCannotRenderSystemWidgets() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
|
||||
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess")));
|
||||
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet())
|
||||
.containsAll(EdiHome.HOME_WIDGET_NAMES).doesNotContainAnyElementsOf(EdiHome.ADMIN_WIDGET_NAMES);
|
||||
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
|
||||
{
|
||||
assertThatThrownBy(() -> new RenderWidgetAction().execute(render(instance, name)))
|
||||
.as(name).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
assertThat(new RenderWidgetAction().execute(render(instance, "edi_home_queue")).getWidgetData()).isInstanceOf(TableData.class);
|
||||
|
||||
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess", "edi.admin.hasAccess")));
|
||||
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet()).containsAll(EdiHome.ADMIN_WIDGET_NAMES);
|
||||
for(String name : EdiHome.ADMIN_WIDGET_NAMES)
|
||||
{
|
||||
assertThat(new RenderWidgetAction().execute(render(instance, name)).getWidgetData()).as(name).isNotNull();
|
||||
}
|
||||
|
||||
QContext.setQSession(readers(new QSession()));
|
||||
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets().keySet()).noneMatch(name -> name.startsWith("edi_home_"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** "Inbound documents are delayed" appears only past the threshold, with the
|
||||
** age and a link to the filtered inbound documents; hidden otherwise.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void delayedInboundOnlyPastThreshold() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
assertThat(EdiRuntime.current().config().getInboundDelayedAfterMinutes()).as("single-node default").isEqualTo(60);
|
||||
QContext.setQSession(readers(new QSession().withPermissions("edi.hasAccess")));
|
||||
assertThat(delayed(instance, RECEIVED).getHideWidget()).as("nothing waiting").isTrue();
|
||||
|
||||
SystemWrites.call(() -> SystemWrites.insert("edi_inbox", new QRecord().withValue("transportType", "SFTP").withValue("externalId", "synthetic-delayed")
|
||||
.withValue("fileName", "synthetic.edi").withValue("payloadRef", "synthetic").withValue("payloadSha256", "0").withValue("sizeBytes", 1)
|
||||
.withValue("receivedAt", RECEIVED).withValue("status", "PENDING").withValue("attempts", 0).withValue("nextAttemptAt", RECEIVED)));
|
||||
|
||||
assertThat(delayed(instance, RECEIVED.plus(30, ChronoUnit.MINUTES)).getHideWidget()).as("within the threshold").isTrue();
|
||||
|
||||
AlertData alert = delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES));
|
||||
assertThat(alert.getHideWidget()).isFalse();
|
||||
assertThat(alert.getAlertType()).isEqualTo(AlertData.AlertType.WARNING);
|
||||
assertThat(alert.getHtml()).contains("Inbound documents are delayed", "1 document has waited more than 1 h", "the oldest has waited 1 h 35 m")
|
||||
.contains("href=\"/app/edi_inbox?filter=").doesNotContain("-1");
|
||||
String filter = URLDecoder.decode(alert.getHtml().replaceAll(".*\\?filter=([^\"]*)\".*", "$1"), StandardCharsets.UTF_8);
|
||||
assertThat(filter).contains("receivedAt", "LESS_THAN", "PENDING");
|
||||
|
||||
EdiRuntime.current().config().withInboundDelayedAfterMinutes(120);
|
||||
assertThat(delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES)).getHideWidget()).as("configurable threshold").isTrue();
|
||||
EdiRuntime.current().config().withInboundDelayedAfterMinutes(60);
|
||||
|
||||
QContext.setQSession(new QSession().withPermissions("edi.hasAccess").withSecurityKeyValue("edi.admin", true));
|
||||
assertThat(delayed(instance, RECEIVED.plus(95, ChronoUnit.MINUTES)).getHideWidget()).as("viewer cannot read inbound documents").isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A setup count that fails shows "Unavailable", never -1.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void setupCountFailureIsUnavailable() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
QSession session = readers(new QSession().withPermissions("edi.hasAccess", "edi.admin.hasAccess"));
|
||||
session.getPermissions().remove("edi.transport.read");
|
||||
QContext.setQSession(session);
|
||||
var data = (TableData) new RenderWidgetAction().execute(render(instance, "edi_home_setup")).getWidgetData();
|
||||
assertThat(data.getRows()).anySatisfy(row -> assertThat(row).containsEntry("Step", "5. Transport").containsEntry("Status", "Unavailable"));
|
||||
assertThat(data.getRows()).allSatisfy(row -> assertThat(row.values()).noneMatch(value -> String.valueOf(value).contains("-1")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the delayed-inbound alert at a given time.
|
||||
*******************************************************************************/
|
||||
private static AlertData delayed(QInstance instance, Instant now) throws Exception
|
||||
{
|
||||
EdiRuntime.current().config().withClock(Clock.fixed(now, ZoneOffset.UTC));
|
||||
return ((AlertData) new RenderWidgetAction().execute(render(instance, "edi_home_delayed")).getWidgetData());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A render input that enforces the viewer's permissions, as the UI API does.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetInput render(QInstance instance, String widgetName)
|
||||
{
|
||||
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(widgetName));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (input);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Add read on every EDI table and all-partner row access.
|
||||
*******************************************************************************/
|
||||
private static QSession readers(QSession session)
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
if(table.getName().startsWith("edi_"))
|
||||
{
|
||||
session.withPermissions(PermissionsHelper.getEffectivePermissionBaseName(PermissionsHelper.getEffectivePermissionRules(table, instance), table.getName()) + ".read");
|
||||
}
|
||||
}
|
||||
return (session.withSecurityKeyValue("edi.admin", true));
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.edi.metadata.EdiTables;
|
||||
import com.kingsrook.qbits.edi.security.EdiClassifications;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
|
||||
@@ -52,6 +53,34 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
*******************************************************************************/
|
||||
class EdiPresentationTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Core classification resolution preserves PHI/PII and the confidential default.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void coreClassifications() throws Exception
|
||||
{
|
||||
var instance = H2Support.create();
|
||||
for(var table : EdiTables.tables(new EdiQBitConfig().withBackendName("edi")))
|
||||
{
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofTable(table)).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
}
|
||||
for(String[] pair : List.of(new String[] {"edi_transaction", "phiBusinessKey"}, new String[] {"edi_transaction_error", "valueExcerpt"},
|
||||
new String[] {"edi_map_sample", "expectedResultJson"}, new String[] {"edi_inbox", "fileName"}, new String[] {"edi_payload", "ciphertext"}))
|
||||
{
|
||||
var table = instance.getTable(pair[0]);
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(table, pair[1])).isEqualTo(DataClassification.PHI);
|
||||
assertThat(AiFieldMetaData.of(table.getField(pair[1])).getExposure()).isEqualTo(com.kingsrook.qqq.backend.core.actions.ai.AiExposure.NONE);
|
||||
}
|
||||
for(String field : List.of("contactName", "contactEmail", "contactPhone"))
|
||||
{
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("edi_partner"), field)).isEqualTo(DataClassification.PII);
|
||||
}
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("edi_partner"), "code")).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
HealthcareSupport.tables();
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("sample_claim"), "patientName")).isEqualTo(DataClassification.PHI);
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("sample_claim_line"), "parentId")).isEqualTo(DataClassification.CONFIDENTIAL);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All registered tables have readable labels and linked internal references.
|
||||
*******************************************************************************/
|
||||
@@ -79,8 +108,8 @@ class EdiPresentationTest
|
||||
}
|
||||
for(String name : table.getRecordLabelFields() == null ? List.<String>of() : table.getRecordLabelFields())
|
||||
{
|
||||
var classification = (EdiClassifications) table.getField(name).getSupplementalMetaData().get("edi.classification");
|
||||
assertThat(classification.getClassification()).as(table.getName() + "." + name).isNotEqualTo("PHI");
|
||||
var classification = AiFieldMetaData.of(table.getField(name));
|
||||
assertThat(classification.getClassification()).as(table.getName() + "." + name).isNotEqualTo(DataClassification.PHI);
|
||||
}
|
||||
}
|
||||
assertThat(instance.getTable("edi_exception").getField("assignedToUserId").getLabel()).isEqualTo("Assigned To");
|
||||
|
||||
@@ -65,6 +65,8 @@ class EdiQBitProducerTest
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getNamespace()).isEqualTo("ns");
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-edi");
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(EdiQBitProducer.VERSION);
|
||||
assertThat(producer.getPackageNameForFindingMetaDataProducers()).isEqualTo("com.kingsrook.qbits.edi.metadata");
|
||||
assertThat(config.getAppName()).isEqualTo("edi");
|
||||
assertThat(config.getAppLabel()).isEqualTo("EDI");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.app.EdiSetupStatus;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup status on the EDI home: the steps in setup order, each with its
|
||||
** state and next step, counting only the rows the runtime would use.
|
||||
*******************************************************************************/
|
||||
class EdiSetupStatusTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The pure logic: order, states and next steps.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stepsAreInSetupOrder()
|
||||
{
|
||||
List<EdiSetupStatus.Item> items = EdiSetupStatus.items(new EdiSetupStatus.Counts(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 41, 20, 0, 0));
|
||||
assertThat(items).extracting(EdiSetupStatus.Item::step).containsExactly("1. Partner", "2. Identities", "3. Agreement", "4. Agreement documents", "5. Transport",
|
||||
"Certificates", "Guides and code lists", "Maps");
|
||||
assertThat(items.subList(0, 5)).allSatisfy(item -> assertThat(item.state()).isEqualTo(EdiSetupStatus.State.TODO));
|
||||
assertThat(items.get(5).state()).isEqualTo(EdiSetupStatus.State.OPTIONAL);
|
||||
assertThat(items.get(6).state()).isEqualTo(EdiSetupStatus.State.BUILT_IN);
|
||||
assertThat(items.get(7).state()).isEqualTo(EdiSetupStatus.State.OPTIONAL);
|
||||
assertThat(items.get(7).nextStep()).contains("Install sample maps");
|
||||
|
||||
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0));
|
||||
assertThat(items.get(1).nextStep()).contains("partner's");
|
||||
assertThat(items.get(6).state()).isEqualTo(EdiSetupStatus.State.TODO);
|
||||
assertThat(items.get(6).nextStep()).contains("EdiRuntime.initialize()");
|
||||
assertThat(items.get(7).state()).as("maps exist but none active").isEqualTo(EdiSetupStatus.State.TODO);
|
||||
|
||||
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(2, 1, 2, 2, 1, 3, 3, 1, 1, 1, 41, 20, 5, 2));
|
||||
assertThat(items).allSatisfy(item -> assertThat(item.state()).isIn(EdiSetupStatus.State.DONE, EdiSetupStatus.State.BUILT_IN));
|
||||
|
||||
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(1, 1, 1, 2, 0, 1, 1, 1, 1, 0, 41, 20, 0, 0));
|
||||
assertThat(items.get(2).state()).as("agreements exist but none is active, so the runtime ignores them").isEqualTo(EdiSetupStatus.State.TODO);
|
||||
|
||||
items = EdiSetupStatus.items(new EdiSetupStatus.Counts(null, null, null, null, null, null, null, null, null, null, null, null, null, null));
|
||||
assertThat(items).allSatisfy(item -> assertThat(item.state()).isEqualTo(EdiSetupStatus.State.UNKNOWN));
|
||||
assertThat(EdiSetupStatus.State.UNKNOWN.label()).as("a failed count is never shown as a number").isEqualTo("Unavailable");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The home widget moves from "needs setup" to "set up" as the rows are added.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeWidgetFollowsTheData() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
EdiRuntime.current().initialize();
|
||||
assertThat(instance.getWidget("edi_home_setup")).isNotNull();
|
||||
assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets()).as("setup is for the Admin home").doesNotContain("edi_home_setup");
|
||||
assertThat(com.kingsrook.qbits.edi.app.EdiHome.ADMIN_WIDGET_NAMES).contains("edi_home_setup");
|
||||
|
||||
Map<String, String> status = status(instance);
|
||||
assertThat(status).containsKeys("1. Partner", "5. Transport");
|
||||
assertThat(status.get("1. Partner")).startsWith("Needs setup");
|
||||
assertThat(status.get("2. Identities")).startsWith("Needs setup");
|
||||
assertThat(status.get("Guides and code lists")).startsWith("Built in: 41 guides");
|
||||
assertThat(status.get("Maps")).startsWith("Optional");
|
||||
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
QRecord partner = SystemWrites.insert("edi_partner", new QRecord().withValue("name", "Synthetic Retailer").withValue("code", "SYNTH"));
|
||||
SystemWrites.insert("edi_identity", new QRecord().withValue("standard", "X12").withValue("interchangeQualifier", "ZZ").withValue("interchangeId", "QQQTEST")
|
||||
.withValue("applicationCode", "QQQTEST").withValue("isActive", true));
|
||||
SystemWrites.insert("edi_identity", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("standard", "X12").withValue("interchangeQualifier", "ZZ")
|
||||
.withValue("interchangeId", "SYNTHETIC").withValue("applicationCode", "SYNTHETIC").withValue("isActive", false));
|
||||
return (null);
|
||||
});
|
||||
status = status(instance);
|
||||
assertThat(status.get("1. Partner")).startsWith("Set up");
|
||||
assertThat(status.get("2. Identities")).as("the partner's identity is inactive, so the runtime would ignore it").startsWith("Needs setup: Yours is set; no partner has one");
|
||||
assertThat(status.get("3. Agreement")).startsWith("Needs setup");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the widget and return step to "state: detail".
|
||||
*******************************************************************************/
|
||||
private Map<String, String> status(QInstance instance) throws Exception
|
||||
{
|
||||
var data = (TableData) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(instance.getWidget("edi_home_setup"))).getWidgetData();
|
||||
Map<String, String> status = new LinkedHashMap<>();
|
||||
data.getRows().forEach(row -> status.put(String.valueOf(row.get("Step")), String.valueOf(row.get("Status"))));
|
||||
return (status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.edi.guide.BuiltInGuideLoader;
|
||||
import com.kingsrook.qbits.edi.metadata.EdiHelp;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every edi_* table:
|
||||
** - runtime code (not just its model, metadata or home screen) reads or
|
||||
** writes it;
|
||||
** - it is either seeded by the qbit at startup, or listed here with why it
|
||||
** starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get created
|
||||
** (shown on the table screen and when it is empty).
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class EdiTablesGuardrailTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "edi");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables the qbit seeds itself (EdiRuntime.initialize, idempotent).
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> SEEDED = Map.of(
|
||||
"edi_guide", "BuiltInGuideLoader: 41 built-in guides",
|
||||
"edi_guide_segment", "with each built-in guide",
|
||||
"edi_guide_element", "with each built-in guide",
|
||||
"edi_guide_rule", "with each built-in guide",
|
||||
"edi_code_list", "with the built-in guides",
|
||||
"edi_code_list_value", "with the built-in guides");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("edi_partner", "host data: the trading partners"),
|
||||
Map.entry("edi_identity", "host data: your ISA/UNB identity and each partner's"),
|
||||
Map.entry("edi_agreement", "host data: the agreement per partner"),
|
||||
Map.entry("edi_agreement_document", "host data: transaction sets per agreement"),
|
||||
Map.entry("edi_transport", "host connection config: addresses and secret variable names"),
|
||||
Map.entry("edi_certificate", "host data: AS2 certificates (optional)"),
|
||||
Map.entry("edi_map", "host data; the 'Install sample maps' action adds bundled examples as inactive, unbound drafts"),
|
||||
Map.entry("edi_map_rule", "with a map; 'Install sample maps' adds the rules of each sample"),
|
||||
Map.entry("edi_map_sample", "user content: synthetic documents uploaded with Preview Map"),
|
||||
Map.entry("edi_control_number", "runtime: allocated on the first send for a scope"),
|
||||
Map.entry("edi_inbox", "runtime: every received document"),
|
||||
Map.entry("edi_payload", "runtime: raw bytes, only when payload storage is DATABASE"),
|
||||
Map.entry("edi_interchange", "runtime: every envelope sent or received"),
|
||||
Map.entry("edi_functional_group", "runtime: every group"),
|
||||
Map.entry("edi_transaction", "runtime: every document"),
|
||||
Map.entry("edi_transaction_error", "runtime: validation findings"),
|
||||
Map.entry("edi_exception", "runtime: failures that need a person"),
|
||||
Map.entry("edi_event", "audit log: written at each processing step (EdiJournal), shown on the interchange record"),
|
||||
Map.entry("edi_as2_mdn", "runtime: AS2 receipts sent and received; read to ignore duplicates and replay a sent receipt"),
|
||||
Map.entry("edi_outbound_request", "runtime: a host record queued to be sent"),
|
||||
Map.entry("edi_outbound_message", "runtime: the outbound queue"));
|
||||
|
||||
private QInstance instance;
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
List<String> runtimeSource = new ArrayList<>();
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
files.filter(p -> p.toString().endsWith(".java") && !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")))
|
||||
.forEach(p -> runtimeSource.add(read(p)));
|
||||
}
|
||||
for(String table : tables())
|
||||
{
|
||||
Pattern use = Pattern.compile("\\b" + table + "\\b");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
for(String table : tables())
|
||||
{
|
||||
assertThat(SEEDED.containsKey(table) || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(SEEDED.containsKey(table) && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both").isFalse();
|
||||
}
|
||||
assertThat(tables()).containsAll(STARTS_EMPTY.keySet()).containsAll(SEEDED.keySet());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The built-in guides fill the seeded tables, a second start adds nothing, and
|
||||
** an administrator's edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedIsInsertOnly() throws Exception
|
||||
{
|
||||
H2Support.create();
|
||||
EdiRuntime.current().initialize();
|
||||
Map<String, Integer> first = counts();
|
||||
assertThat(first.get("edi_guide")).isEqualTo(BuiltInGuideLoader.RESOURCES.size()).isEqualTo(41);
|
||||
for(String table : SEEDED.keySet())
|
||||
{
|
||||
assertThat(first.get(table)).as(table + " is seeded").isGreaterThan(0);
|
||||
}
|
||||
QRecord guide = SystemWrites.query("edi_guide", new QQueryFilter()).getFirst();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_guide", new QRecord().withValue("id", guide.getValue("id")).withValue("description", "Edited by an administrator"));
|
||||
return (null);
|
||||
});
|
||||
|
||||
EdiRuntime.current().initialize();
|
||||
assertThat(counts()).isEqualTo(first);
|
||||
assertThat(EdiRecords.query("edi_guide", EdiRecords.equal("id", guide.getValue("id"))).getFirst().getValueString("description")).isEqualTo("Edited by an administrator");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
assertThat(EdiHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
for(String table : tables())
|
||||
{
|
||||
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(EdiHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getHelpContent()).containsKey(EdiHelp.SLOT);
|
||||
assertThat(EdiHelp.entries().get("table:edi_map;slot:help").path("content").asText()).contains("Install sample maps");
|
||||
assertThat(EdiHelp.entries().get("app:edi;slot:help").path("content").asText().indexOf("Partner")).isLessThan(EdiHelp.entries().get("app:edi;slot:help").path("content").asText().indexOf("Transport"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Help a host put in the slot first is never replaced by the bundle.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsKept() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
String appName = EdiRuntime.current().config().getAppName();
|
||||
QHelpContent host = new QHelpContent().withContentAsMarkdown("Host help for partners");
|
||||
instance.getTable("edi_partner").getHelpContent().put(EdiHelp.SLOT, new ArrayList<>(List.of(host)));
|
||||
instance.getApp(appName).getHelpContent().put(EdiHelp.SLOT, new ArrayList<>(List.of(host)));
|
||||
EdiHelp.apply(instance, appName);
|
||||
assertThat(instance.getTable("edi_partner").getHelpContent().get(EdiHelp.SLOT)).containsExactly(host);
|
||||
assertThat(instance.getApp(appName).getHelpContent().get(EdiHelp.SLOT)).containsExactly(host);
|
||||
assertThat(instance.getTable("edi_identity").getHelpContent().get(EdiHelp.SLOT).get(0).getContent()).contains("How rows get created");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Row counts of the seeded tables.
|
||||
*******************************************************************************/
|
||||
private Map<String, Integer> counts() throws Exception
|
||||
{
|
||||
Map<String, Integer> counts = new java.util.TreeMap<>();
|
||||
for(String table : SEEDED.keySet())
|
||||
{
|
||||
counts.put(table, SystemWrites.count(table, new QQueryFilter()));
|
||||
}
|
||||
return (counts);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's tables (the preview download backend table is not data).
|
||||
*******************************************************************************/
|
||||
private List<String> tables() throws Exception
|
||||
{
|
||||
if(instance == null)
|
||||
{
|
||||
instance = H2Support.create();
|
||||
}
|
||||
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName)
|
||||
.filter(name -> name.startsWith("edi_") && !name.equals("edi_preview_download")).sorted().toList();
|
||||
assertThat(tables).hasSize(27);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qbits.edi.x12.X12Separators;
|
||||
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
|
||||
import com.kingsrook.qbits.edi.x12.X12Writer;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
@@ -93,7 +95,7 @@ public final class HealthcareSupport
|
||||
{
|
||||
String[] parts = field.split(":");
|
||||
table.withField(new QFieldMetaData(parts[0], parts.length > 1 ? QFieldType.valueOf(parts[1]) : QFieldType.STRING).withBackendName(QInstanceEnricher.inferBackendName(parts[0]))
|
||||
.withSupplementalMetaData(new com.kingsrook.qbits.edi.security.EdiClassifications(parts[0].equals("parentId") ? "CONFIDENTIAL" : "PHI")));
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(parts[0].equals("parentId") ? DataClassification.CONFIDENTIAL : DataClassification.PHI)));
|
||||
}
|
||||
instance.addTable(table);
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) instance.getBackend(backend)); var statement = connection.createStatement())
|
||||
|
||||
@@ -67,6 +67,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Synthetic H2 vertical slice, including permission, retry and UI boundaries.
|
||||
@@ -337,6 +338,15 @@ class InboundTest
|
||||
*******************************************************************************/
|
||||
public static class CapturedAlerts implements com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface
|
||||
{
|
||||
static final List<com.kingsrook.qqq.backend.core.alerts.NotificationSignal> notifications = new ArrayList<>();
|
||||
/*******************************************************************************
|
||||
** Capture actual core notification dispatch.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(com.kingsrook.qqq.backend.core.alerts.NotificationSignal signal)
|
||||
{
|
||||
notifications.add(signal);
|
||||
}
|
||||
static final List<String> raised = new ArrayList<>();
|
||||
static final List<String> resolved = new ArrayList<>();
|
||||
/*******************************************************************************
|
||||
@@ -409,6 +419,9 @@ class InboundTest
|
||||
return (null);
|
||||
});
|
||||
QRecord exception = rows("edi_exception").getFirst();
|
||||
CapturedAlerts.notifications.clear();
|
||||
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance())
|
||||
.withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
|
||||
for(String action : List.of("assign", "ignore", "resolve"))
|
||||
{
|
||||
input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName("edi_exception_" + action).withRecords(List.of(exception))
|
||||
@@ -417,6 +430,85 @@ class InboundTest
|
||||
}
|
||||
assertThat(rows("edi_exception").getFirst().getValueString("status")).isEqualTo("RESOLVED");
|
||||
assertThat(rows("edi_exception").getFirst().getValueString("assignedToUserId")).isEqualTo("synthetic-user");
|
||||
assertThat(CapturedAlerts.notifications).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAudience().getValues()).containsExactly("synthetic-user");
|
||||
assertThat(signal.getTitle()).isEqualTo("EDI exception assigned");
|
||||
assertThat(signal.getBody()).isEqualTo("An EDI exception has been assigned to you.");
|
||||
assertThat(signal.getSourceTableName()).isEqualTo("edi_exception");
|
||||
assertThat(signal.getSourceRecordId()).isEqualTo(exception.getValueString("id"));
|
||||
});
|
||||
input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName("edi_exception_assign")
|
||||
.withRecords(List.of(exception)).withValues(Map.of("assignedToUserId", " "));
|
||||
output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
|
||||
execute.runOnePage(input, output);
|
||||
assertThat(execute.getProcessSummary(output, true).toString()).contains("record could not be processed");
|
||||
assertThat(CapturedAlerts.notifications).hasSize(1);
|
||||
assertThat(rows("edi_exception").getFirst().getValueString("status")).isEqualTo("RESOLVED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Newly opened partner failures notify current assignees once, after commit only.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"REJECTED_ACK", "DEAD_LETTER"})
|
||||
void notifyPartnerAssignees(String type) throws Exception
|
||||
{
|
||||
setup("004010");
|
||||
CapturedAlerts.notifications.clear();
|
||||
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance())
|
||||
.withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
|
||||
Integer partnerId = partner.getValueInteger("id");
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
for(String status : List.of("OPEN", "IN_PROGRESS", "RESOLVED", "IGNORED"))
|
||||
{
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("partnerId", partnerId).withValue("type", "APPLY")
|
||||
.withValue("status", status).withValue("assignedToUserId", List.of("OPEN", "IN_PROGRESS").contains(status) ? "current-user" : "closed-user"));
|
||||
}
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("partnerId", partnerId + 1).withValue("type", "APPLY")
|
||||
.withValue("status", "OPEN").withValue("assignedToUserId", "other-partner-user"));
|
||||
return (null);
|
||||
});
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Synthetic private text");
|
||||
assertThat(CapturedAlerts.notifications).isEmpty();
|
||||
return (null);
|
||||
}));
|
||||
assertThat(CapturedAlerts.notifications).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAudience().getValues()).containsExactly("current-user");
|
||||
assertThat(signal.getTitle()).isEqualTo("EDI exception opened");
|
||||
assertThat(signal.getBody()).isEqualTo("An acknowledgement rejection or dead-letter exception needs attention.");
|
||||
assertThat(signal.getSourceTableName()).isEqualTo("edi_exception");
|
||||
assertThat(signal.getSourceRecordId()).isNotBlank();
|
||||
});
|
||||
CapturedAlerts.notifications.clear();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Repeated failure");
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(101, null, null, null, type, "No partner");
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(102, null, null, partnerId, "VALIDATION", "Other type");
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(103, null, null, partnerId + 2, type, "No assignees");
|
||||
return (null);
|
||||
});
|
||||
assertThat(CapturedAlerts.notifications).isEmpty();
|
||||
assertThatThrownBy(() -> SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(104, null, null, partnerId, type, "Rolled back");
|
||||
throw new IllegalStateException("Synthetic rollback");
|
||||
}))).isInstanceOf(Exception.class);
|
||||
assertThat(CapturedAlerts.notifications).isEmpty();
|
||||
assertThat(EdiRecords.query("edi_exception", EdiRecords.equal("inboxId", 104))).isEmpty();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
QRecord row = EdiRecords.query("edi_exception", EdiRecords.equal("inboxId", 100)).getFirst();
|
||||
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("status", "RESOLVED"));
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Reopened");
|
||||
return (null);
|
||||
});
|
||||
assertThat(CapturedAlerts.notifications).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -652,4 +744,32 @@ class InboundTest
|
||||
assertThat(rows("sample_order_line")).isEmpty();
|
||||
assertThat(rows("edi_exception")).extracting(r -> r.getValueString("type")).contains("APPLY");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An inactive identity stops X12 routing, as it does for EDIFACT.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inactiveIdentityStopsX12Routing() throws Exception
|
||||
{
|
||||
setup("004010");
|
||||
QRecord remote = rows("edi_identity").stream().filter(r -> r.getValue("partnerId") != null).findFirst().orElseThrow();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_identity", new QRecord().withValue("id", remote.getValue("id")).withValue("isActive", false));
|
||||
return (null);
|
||||
});
|
||||
receive(raw(), "inactive");
|
||||
new InboxWorker().runOnce();
|
||||
assertThat(rows("sample_order")).isEmpty();
|
||||
assertThat(rows("edi_exception")).extracting(r -> r.getValueString("type")).contains("UNKNOWN_PARTNER");
|
||||
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_identity", new QRecord().withValue("id", remote.getValue("id")).withValue("isActive", true));
|
||||
return (null);
|
||||
});
|
||||
receive(Synthetic850.generate(18, "004010", X12Separators.DEFAULT), "active");
|
||||
new InboxWorker().runOnce();
|
||||
assertThat(rows("sample_order")).hasSize(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.map.SampleMaps;
|
||||
import com.kingsrook.qbits.edi.processes.InstallSampleMapsProcess;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Install sample maps" table action on edi_map: standard streamed ETL,
|
||||
** insert-only by name, installing inactive and unbound drafts.
|
||||
*******************************************************************************/
|
||||
class InstallSampleMapsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Every bundled sample is listed, and every listed sample exists.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theListMatchesTheBundledFiles()
|
||||
{
|
||||
String[] files = new File("src/main/resources/edi/maps").list((dir, name) -> name.endsWith("-sample.json"));
|
||||
assertThat(Arrays.stream(files).map(name -> name.replace("-sample.json", "")).toList()).containsExactlyInAnyOrderElementsOf(SampleMaps.RESOURCES);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A standard streamed-ETL table action that needs no selected records.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void isAStandardTableAction() throws Exception
|
||||
{
|
||||
QInstance instance = H2Support.create();
|
||||
QProcessMetaData process = instance.getProcess(InstallSampleMapsProcess.NAME);
|
||||
assertThat(process).isNotNull();
|
||||
assertThat(process.getTableName()).isEqualTo("edi_map");
|
||||
assertThat(process.getMinInputRecords()).isEqualTo(0);
|
||||
assertThat(process.getPermissionRules().getPermissionBaseName()).isEqualTo("edi.admin");
|
||||
assertThat(process.getStepList()).extracting(step -> step.getName()).contains("review", "result");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Installs every sample as an inactive, unbound draft with its rules; a
|
||||
** second run adds nothing; an edited map is kept; a deleted one comes back.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void installsDraftsInsertOnly() throws Exception
|
||||
{
|
||||
H2Support.create();
|
||||
EdiRuntime.current().initialize();
|
||||
assertThat(count("edi_map")).isZero();
|
||||
|
||||
run();
|
||||
assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size());
|
||||
List<QRecord> maps = SystemWrites.query("edi_map", new QQueryFilter());
|
||||
assertThat(maps).allSatisfy(map ->
|
||||
{
|
||||
assertThat(map.getValueString("status")).as(map.getValueString("name")).isEqualTo("DRAFT");
|
||||
assertThat(map.getValueInteger("revision")).isZero();
|
||||
assertThat(map.getValue("partnerId")).as("unbound to a partner").isNull();
|
||||
assertThat(map.getValue("guideId")).as("bound to the built-in guide for its document").isNotNull();
|
||||
});
|
||||
assertThat(count("edi_agreement_document")).as("bound to no agreement document").isZero();
|
||||
|
||||
QRecord sample850 = maps.stream().filter(map -> "Sample 850 004010".equals(map.getValueString("name"))).findFirst().orElseThrow();
|
||||
int rules = count("edi_map_rule");
|
||||
assertThat(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", sample850.getValue("id")))).hasSize(5);
|
||||
assertThat(rules).isGreaterThan(SampleMaps.RESOURCES.size());
|
||||
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map", new QRecord().withValue("id", sample850.getValue("id")).withValue("tableName", "my_orders").withValue("status", "ACTIVE"));
|
||||
return (null);
|
||||
});
|
||||
run();
|
||||
assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size());
|
||||
assertThat(count("edi_map_rule")).isEqualTo(rules);
|
||||
QRecord edited = EdiRecords.query("edi_map", EdiRecords.equal("id", sample850.getValue("id"))).getFirst();
|
||||
assertThat(edited.getValueString("tableName")).isEqualTo("my_orders");
|
||||
assertThat(edited.getValueString("status")).isEqualTo("ACTIVE");
|
||||
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("edi_map_rule")
|
||||
.withQueryFilter(new QQueryFilter().withCriteria("mapId", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, sample850.getValue("id"))));
|
||||
new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("edi_map")
|
||||
.withPrimaryKey(sample850.getValue("id")));
|
||||
return (null);
|
||||
});
|
||||
assertThat(SampleMaps.RESOURCES).hasSize(32);
|
||||
assertThat(SystemWrites.call(SampleMaps::missing)).containsExactly("850-004010");
|
||||
run();
|
||||
assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size());
|
||||
assertThat(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", EdiRecords.query("edi_map", EdiRecords.equal("name", "Sample 850 004010")).getFirst().getValue("id")))).hasSize(5);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that already has a map of the same name keeps it, and gets no copy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void neverTouchesAMapTheHostAlreadyHas() throws Exception
|
||||
{
|
||||
H2Support.create();
|
||||
EdiRuntime.current().initialize();
|
||||
SystemWrites.call(() -> SystemWrites.insert("edi_map", new QRecord().withValue("name", "Sample 850 004010").withValue("direction", "INBOUND")
|
||||
.withValue("transactionSet", "850").withValue("version", "004010").withValue("status", "ACTIVE").withValue("revision", 3).withValue("tableName", "host_orders")));
|
||||
run();
|
||||
List<QRecord> same = EdiRecords.query("edi_map", EdiRecords.equal("name", "Sample 850 004010"));
|
||||
assertThat(same).hasSize(1);
|
||||
assertThat(same.getFirst().getValueString("tableName")).isEqualTo("host_orders");
|
||||
assertThat(same.getFirst().getValueString("status")).isEqualTo("ACTIVE");
|
||||
assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size());
|
||||
assertThat(count("edi_map_rule")).as("no rules were added to the host's map").isEqualTo(
|
||||
SampleMaps.RESOURCES.stream().filter(r -> !r.equals("850-004010")).mapToInt(InstallSampleMapsTest::ruleCount).sum());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the real process through the standard runner (frontend steps skipped).
|
||||
*******************************************************************************/
|
||||
private void run() throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(InstallSampleMapsProcess.NAME);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
new RunProcessAction().execute(input);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The number of rules a bundled sample carries.
|
||||
*******************************************************************************/
|
||||
private static int ruleCount(String resource)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (SampleMaps.read(resource).path("rules").size());
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private int count(String table) throws Exception
|
||||
{
|
||||
return (SystemWrites.count(table, new QQueryFilter()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundEnqueue;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundSourceTables;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** The record-change listener only costs queries for configured source tables,
|
||||
** and its cache follows agreement and document changes.
|
||||
*******************************************************************************/
|
||||
class OutboundSourceTablesTest
|
||||
{
|
||||
private QRecord document;
|
||||
private QRecord agreement;
|
||||
|
||||
/*******************************************************************************
|
||||
** One active RECORD_CHANGE agreement document, for sample_order.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
H2Support.create();
|
||||
RetailSupport.tables();
|
||||
EdiRuntime.current().initialize();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
QRecord partner = SystemWrites.insert("edi_partner", new QRecord().withValue("name", "Retail test").withValue("code", "RETAIL").withValue("status", "ACTIVE"));
|
||||
QRecord local = SystemWrites.insert("edi_identity", new QRecord().withValue("standard", "X12").withValue("interchangeQualifier", "ZZ").withValue("interchangeId", "QQQTEST").withValue("applicationCode", "QQQTEST"));
|
||||
QRecord remote = SystemWrites.insert("edi_identity", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("standard", "X12").withValue("interchangeQualifier", "ZZ").withValue("interchangeId", "SYNTHETIC").withValue("applicationCode", "SYNTHETIC"));
|
||||
QRecord transport = SystemWrites.insert("edi_transport", new QRecord().withValue("name", "retail-out").withValue("type", "GATEWAY").withValue("direction", "BOTH").withValue("gatewayEndpointName", "retail-outbox").withValue("isActive", true).withValue("maxAttempts", 3));
|
||||
agreement = SystemWrites.insert("edi_agreement", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("localIdentityId", local.getValue("id")).withValue("partnerIdentityId", remote.getValue("id"))
|
||||
.withValue("standard", "X12").withValue("usageIndicator", "T").withValue("isActive", true).withValue("outboundTransportId", transport.getValue("id")));
|
||||
QRecord map = RetailSupport.installMap("855", "004010");
|
||||
document = SystemWrites.insert("edi_agreement_document", outboundDocument(map, "sample_order"));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reset context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes to a table without a definition neither apply nor reload definitions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unconfiguredTableDoesNoWork() throws Exception
|
||||
{
|
||||
OutboundEnqueue listener = new OutboundEnqueue();
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
long loads = sources().loadCount();
|
||||
|
||||
assertThat(listener.appliesTo("sample_invoice", RecordChangeType.INSERT)).isFalse();
|
||||
assertThat(listener.appliesTo("sample_invoice", RecordChangeType.UPDATE)).isFalse();
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.DELETE)).isFalse();
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
for(int i = 0; i < 5; i++)
|
||||
{
|
||||
RetailSupport.saveSource("sample_invoice", new QRecord().withValue("invoiceNumber", "INV-" + i));
|
||||
}
|
||||
return (null);
|
||||
}));
|
||||
|
||||
assertThat(sources().loadCount()).isEqualTo(loads);
|
||||
assertThat(rows("edi_outbound_request")).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured tables still enqueue, once per record, from one cached load.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configuredTableEnqueues() throws Exception
|
||||
{
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source())));
|
||||
long loads = sources().loadCount();
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source().withValue("poNumber", "PO-2"))));
|
||||
assertThat(sources().loadCount()).isEqualTo(loads);
|
||||
assertThat(rows("edi_outbound_request")).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Document and agreement changes invalidate the cached tables.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void definitionChangesInvalidate() throws Exception
|
||||
{
|
||||
OutboundEnqueue listener = new OutboundEnqueue();
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
assertThat(listener.appliesTo("edi_agreement_document", RecordChangeType.DELETE)).isTrue();
|
||||
|
||||
update("edi_agreement_document", document.getValue("id"), "isActive", false);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isFalse();
|
||||
|
||||
update("edi_agreement_document", document.getValue("id"), "isActive", true);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
|
||||
update("edi_agreement", agreement.getValue("id"), "isActive", false);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isFalse();
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source())));
|
||||
assertThat(rows("edi_outbound_request")).isEmpty();
|
||||
|
||||
update("edi_agreement", agreement.getValue("id"), "isActive", true);
|
||||
assertThat(listener.appliesTo("sample_order", RecordChangeType.INSERT)).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A transaction that adds a definition sees it before commit; a rolled-back
|
||||
** one leaves the cache on committed data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void changingTransactionSeesItsOwnDefinitions() throws Exception
|
||||
{
|
||||
QRecord map = SystemWrites.call(() -> EdiRecords.query("edi_map", new QQueryFilter()).getFirst());
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
SystemWrites.insert("edi_agreement_document", outboundDocument(map, "sample_invoice").withValue("transactionSet", "810"));
|
||||
RetailSupport.saveSource("sample_invoice", new QRecord().withValue("invoiceNumber", "INV-1"));
|
||||
return (null);
|
||||
}));
|
||||
assertThat(rows("edi_outbound_request")).hasSize(1);
|
||||
assertThat(new OutboundEnqueue().appliesTo("sample_invoice", RecordChangeType.INSERT)).isTrue();
|
||||
|
||||
try
|
||||
{
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
SystemWrites.insert("edi_agreement_document", outboundDocument(map, "sample_inventory").withValue("transactionSet", "846"));
|
||||
throw new IllegalStateException("rollback synthetic write");
|
||||
}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
////////////////////////////
|
||||
// expected - rolled back //
|
||||
////////////////////////////
|
||||
}
|
||||
assertThat(new OutboundEnqueue().appliesTo("sample_inventory", RecordChangeType.INSERT)).isFalse();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An active outbound RECORD_CHANGE document for the source table.
|
||||
*******************************************************************************/
|
||||
private QRecord outboundDocument(QRecord map, String sourceTable)
|
||||
{
|
||||
return (new QRecord().withValue("agreementId", agreement.getValue("id")).withValue("direction", "OUTBOUND").withValue("transactionSet", "855")
|
||||
.withValue("version", "004010").withValue("functionalIdCode", "PR").withValue("guideId", map.getValue("guideId")).withValue("mapId", map.getValue("id"))
|
||||
.withValue("sourceTableName", sourceTable).withValue("outboundTrigger", "RECORD_CHANGE").withValue("batchMode", "IMMEDIATE").withValue("ackExpectation", "997").withValue("ackDueMinutes", 1).withValue("isActive", true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Update one field through QQQ, outside any transaction.
|
||||
*******************************************************************************/
|
||||
private static void update(String table, Object id, String field, Object value) throws Exception
|
||||
{
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update(table, new QRecord().withValue("id", (java.io.Serializable) id).withValue(field, (java.io.Serializable) value));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime cache under test.
|
||||
*******************************************************************************/
|
||||
private static OutboundSourceTables sources()
|
||||
{
|
||||
return (EdiRuntime.current().outboundSources());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All rows.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> rows(String table) throws Exception
|
||||
{
|
||||
return (SystemWrites.call(() -> EdiRecords.query(table, new QQueryFilter())));
|
||||
}
|
||||
}
|
||||
@@ -329,4 +329,60 @@ class OutboundTest
|
||||
{
|
||||
return (SystemWrites.call(() -> EdiRecords.query(table, new QQueryFilter())));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeat-seconds worker schedules carry no cron time zone (core instance
|
||||
** validation refuses one on a non-cron schedule); cron schedules use UTC.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void repeatSchedulesHaveNoCronTimeZone()
|
||||
{
|
||||
com.kingsrook.qbits.edi.EdiQBitConfig config = new com.kingsrook.qbits.edi.EdiQBitConfig().withSchedulerName("scheduler");
|
||||
var repeat = com.kingsrook.qbits.edi.processes.OutboundProcesses.process("edi_test_repeat", config, 15, null).getSchedule();
|
||||
assertThat(repeat.getRepeatSeconds()).isEqualTo(15);
|
||||
assertThat(repeat.getCronTimeZoneId()).isNull();
|
||||
var cron = com.kingsrook.qbits.edi.processes.OutboundProcesses.process("edi_test_cron", config, null, "0 0 * * * ?").getSchedule();
|
||||
assertThat(cron.getCronTimeZoneId()).isEqualTo("UTC");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A draft map bound to an outbound document is never used: the request fails
|
||||
** as a mapping error and nothing is queued. An inactive identity stops the
|
||||
** document from being built at all; its requests wait.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void draftMapAndInactiveIdentityStopSending() throws Exception
|
||||
{
|
||||
setup();
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map", new QRecord().withValue("id", document.getValue("mapId")).withValue("status", "DRAFT"));
|
||||
return (null);
|
||||
});
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source())));
|
||||
new OutboundWorker().runOnce();
|
||||
assertThat(rows("edi_outbound_message")).isEmpty();
|
||||
assertThat(rows("edi_outbound_request")).extracting(r -> r.getValueString("status")).containsExactly("FAILED");
|
||||
assertThat(rows("edi_exception")).extracting(r -> r.getValueString("type")).contains("MAPPING");
|
||||
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_map", new QRecord().withValue("id", document.getValue("mapId")).withValue("status", "ACTIVE"));
|
||||
SystemWrites.update("edi_identity", new QRecord().withValue("id", agreement.getValue("partnerIdentityId")).withValue("isActive", false));
|
||||
return (null);
|
||||
});
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source().withValue("poNumber", "DEMO-1002"))));
|
||||
assertThat(new OutboundWorker().runOnce()).isZero();
|
||||
assertThat(rows("edi_outbound_message")).isEmpty();
|
||||
assertThat(rows("edi_outbound_request")).extracting(r -> r.getValueString("status")).contains("PENDING");
|
||||
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.update("edi_identity", new QRecord().withValue("id", agreement.getValue("partnerIdentityId")).withValue("isActive", true));
|
||||
return (null);
|
||||
});
|
||||
new OutboundWorker().runOnce();
|
||||
assertThat(rows("edi_outbound_message")).hasSize(1);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import com.kingsrook.qbits.edi.flow.InboxWorker;
|
||||
import com.kingsrook.qbits.edi.flow.SendWorker;
|
||||
import com.kingsrook.qbits.edi.metadata.EdiTables;
|
||||
import com.kingsrook.qbits.edi.processes.EdiProcesses;
|
||||
import com.kingsrook.qbits.edi.security.EdiClassifications;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadCrypto;
|
||||
import com.kingsrook.qbits.edi.storage.PreviewDownloadBackend;
|
||||
@@ -55,6 +54,8 @@ import com.kingsrook.qbits.edi.transport.gateway.EdiFileGatewayConsumer;
|
||||
import com.kingsrook.qbits.edi.transport.gateway.standin.FileGatewayOutbox;
|
||||
import com.kingsrook.qbits.edi.transport.gateway.standin.InboundFile;
|
||||
import com.kingsrook.qbits.edi.transport.gateway.standin.OutboxFileRef;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -78,7 +79,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** End-to-end PHI egress scanner. Only explicit PHI columns may retain sentinels.
|
||||
** RC.5 has no secured-data tier resolver; FULL exports and positional egress run here.
|
||||
** RC.6 has no secured-data tier resolver; FULL exports and positional egress run here.
|
||||
*******************************************************************************/
|
||||
class PhiCanaryTest
|
||||
{
|
||||
@@ -120,8 +121,8 @@ class PhiCanaryTest
|
||||
{
|
||||
for(var field : QContext.getQInstance().getTable(table).getFields().values())
|
||||
{
|
||||
var classification = field.getSupplementalMetaData("edi.classification");
|
||||
if(classification instanceof EdiClassifications classified && classified.getClassification().equals("PHI"))
|
||||
var classification = AiFieldMetaData.of(field);
|
||||
if(classification != null && classification.getClassification() == DataClassification.PHI)
|
||||
{
|
||||
values.remove(field.getName());
|
||||
}
|
||||
@@ -158,6 +159,7 @@ class PhiCanaryTest
|
||||
demo.seed();
|
||||
var instance = demo.defineQInstance();
|
||||
CapturedAlerts.outputs.clear();
|
||||
CapturedAlerts.notifications.clear();
|
||||
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
|
||||
// A test-only widened diagnosis field allows the long marker through mapping;
|
||||
// its invalid code shape still exercises SNIP 5, and separate documents force SNIP 2.
|
||||
@@ -250,6 +252,13 @@ class PhiCanaryTest
|
||||
}
|
||||
for(QRecord exception : rows("edi_exception"))
|
||||
{
|
||||
process("edi_exception_assign", exception);
|
||||
SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.EdiJournal.exception(exception.getValueInteger("inboxId"), exception.getValueInteger("interchangeId"),
|
||||
exception.getValueInteger("transactionId"), exception.getValueInteger("partnerId"), "DEAD_LETTER", "Synthetic PHI failure");
|
||||
return (null);
|
||||
}));
|
||||
process("edi_exception_resolve", exception);
|
||||
}
|
||||
while(new SendWorker().runOnce() > 0)
|
||||
@@ -266,6 +275,8 @@ class PhiCanaryTest
|
||||
SystemWrites.call(() -> { SystemWrites.update("edi_functional_group", new QRecord().withValue("id", group.getValue("id")).withValue("ackStatus", "PENDING").withValue("ackDueAt", Instant.now().minusSeconds(60))); return (null); });
|
||||
new AckMonitor().runOnce();
|
||||
assertThat(CapturedAlerts.outputs).isNotEmpty();
|
||||
assertThat(CapturedAlerts.notifications).anyMatch(json -> json.contains("EDI exception assigned"));
|
||||
assertThat(CapturedAlerts.notifications).anyMatch(json -> json.contains("EDI exception opened"));
|
||||
CapturedAlerts.outputs.forEach(output -> scan(output, "alerts"));
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
@@ -340,7 +351,7 @@ class PhiCanaryTest
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 HIDDEN field locks are real enforcement, not test-side JSON redaction.
|
||||
** RC.6 HIDDEN field locks are real enforcement, not test-side JSON redaction.
|
||||
*******************************************************************************/
|
||||
private void hiddenExportsAndWidgets() throws Exception
|
||||
{
|
||||
@@ -348,7 +359,7 @@ class PhiCanaryTest
|
||||
var enforcement = instance.getEnforcePermissionsInCoreActions();
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
var fullSession = QContext.getQSession();
|
||||
var viewer = new com.kingsrook.qqq.backend.core.model.session.QSession().withPermissions("edi.admin").withSecurityKeyValue("edi.admin", true).withSecurityKeyValue("edi.guide.loader", true);
|
||||
var viewer = new com.kingsrook.qqq.backend.core.model.session.QSession().withPermissions("edi.admin", "edi.hasAccess", "edi.admin.hasAccess").withSecurityKeyValue("edi.admin", true).withSecurityKeyValue("edi.guide.loader", true);
|
||||
for(var table : instance.getTables().values())
|
||||
{
|
||||
if(!table.getName().startsWith("edi_") || table.getName().equals(PreviewDownloadBackend.TABLE))
|
||||
@@ -358,7 +369,7 @@ class PhiCanaryTest
|
||||
viewer.withPermissions("edi." + table.getName().substring(4) + ".read");
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
if(field.getSupplementalMetaData("edi.classification") instanceof EdiClassifications classified && classified.getClassification().equals("PHI"))
|
||||
if(AiFieldMetaData.of(field) != null && AiFieldMetaData.of(field).getClassification() == DataClassification.PHI)
|
||||
{
|
||||
field.withFieldSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock().withSecurityKeyType("synthetic_phi_access"));
|
||||
}
|
||||
@@ -401,6 +412,10 @@ class PhiCanaryTest
|
||||
private RunBackendStepOutput process(String name, QRecord record) throws Exception
|
||||
{
|
||||
var input = new RunBackendStepInput().withProcessName(name).withRecords(List.of(record));
|
||||
if(name.equals("edi_exception_assign"))
|
||||
{
|
||||
input.withValues(Map.of("assignedToUserId", "synthetic-canary-user"));
|
||||
}
|
||||
var review = new EdiProcesses.Review();
|
||||
var preview = new RunBackendStepOutput();
|
||||
review.runOnePage(input, preview);
|
||||
@@ -428,6 +443,19 @@ class PhiCanaryTest
|
||||
public static class CapturedAlerts implements com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface
|
||||
{
|
||||
static final List<String> outputs = new ArrayList<>();
|
||||
static final List<String> notifications = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan the complete notification payload for PHI, including attributes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(com.kingsrook.qqq.backend.core.alerts.NotificationSignal signal)
|
||||
{
|
||||
String json = JsonUtils.toJson(signal);
|
||||
notifications.add(json);
|
||||
outputs.add(json);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain every serialized signal for the substring scanner.
|
||||
|
||||
@@ -68,7 +68,7 @@ class EdiDemoServerTest
|
||||
Set<String> properties = new HashSet<>();
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).fieldNames().forEachRemaining(properties::add);
|
||||
assertThat(properties).isSubsetOf("sendAsEdiTables", "defaultBackendNameForTables", "backendName", "appName", "appLabel", "parentAppName",
|
||||
"schedulerName", "maxSegmentsPerTransaction", "partnerLocks", "payloadBucketAlias", "esbDestinationName");
|
||||
"schedulerName", "maxSegmentsPerTransaction", "partnerLocks", "payloadBucketAlias", "esbDestinationName", "inboundDelayedAfterMinutes");
|
||||
assertThat(calls.get()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user