From 30ac5989526da50bef9d5a981d010714b9bc6cc7 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 09:17:37 -0500 Subject: [PATCH] build: move to QQQ 4.1.0-RC.6 and remove RC.5 stand-ins - qqq-bom 4.1.0-RC.6 (guardrail G7). - Replace the EdiClassifications stand-in with core DataClassification on edi_ tables and fields and on the healthcare sample tables. - Value-free QAlerts.notify notifications for exception assignment and partner exceptions. - Fix the shared-transport possible-value filter grouping. - PhiLogScope kept (RC.6 has no query-log redaction), now marked for core #1147 by RC.7; file-gateway stand-ins kept until that api ships. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 12 +++ CLAUDE.md | 2 +- README.md | 55 ++++++----- docs/DESIGN.md | 44 +++++---- pom.xml | 2 +- .../kingsrook/qbits/edi/flow/EdiJournal.java | 24 ++++- .../qbits/edi/metadata/EdiContingencies.java | 6 +- .../qbits/edi/metadata/EdiTables.java | 16 ++-- .../qbits/edi/processes/EdiProcesses.java | 17 +++- .../edi/security/EdiClassifications.java | 82 ----------------- .../qbits/edi/security/PhiLogScope.java | 8 +- .../qbits/edi/ContingenciesTest.java | 40 +++++++- .../qbits/edi/EdiPresentationTest.java | 35 ++++++- .../qbits/edi/HealthcareSupport.java | 4 +- .../com/kingsrook/qbits/edi/InboundTest.java | 92 +++++++++++++++++++ .../kingsrook/qbits/edi/PhiCanaryTest.java | 40 ++++++-- 17 files changed, 325 insertions(+), 156 deletions(-) delete mode 100644 src/main/java/com/kingsrook/qbits/edi/security/EdiClassifications.java diff --git a/AGENTS.md b/AGENTS.md index ceda55d..f019f87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd0b81..0c503a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,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. diff --git a/CLAUDE.md b/CLAUDE.md index f5cdfe6..dfdd39a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 9c835d3..b83e44b 100644 --- a/README.md +++ b/README.md @@ -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.6+**, 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,7 +57,7 @@ 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. ## Tables @@ -73,7 +73,7 @@ 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. ## Inbound operation @@ -111,7 +111,7 @@ 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. @@ -165,29 +165,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 +305,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 +324,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 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d1b2447..84cf838 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 @@ -770,7 +781,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 +805,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 +953,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 +1008,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 +1024,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) diff --git a/pom.xml b/pom.xml index c8f0297..1997b82 100644 --- a/pom.xml +++ b/pom.xml @@ -213,7 +213,7 @@ true - 4.1.0-RC.5 + 4.1.0-RC.6 diff --git a/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java b/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java index ad9c83c..bba58d5 100644 --- a/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java +++ b/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java @@ -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 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"); } } diff --git a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java index af208bc..5027392 100644 --- a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java +++ b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java @@ -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); } } } diff --git a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java index 6f6c475..6769f9e 100644 --- a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java +++ b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java @@ -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); diff --git a/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java b/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java index 17616ac..1a69597 100644 --- a/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java +++ b/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java @@ -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; @@ -316,13 +319,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 { diff --git a/src/main/java/com/kingsrook/qbits/edi/security/EdiClassifications.java b/src/main/java/com/kingsrook/qbits/edi/security/EdiClassifications.java deleted file mode 100644 index 9cf0186..0000000 --- a/src/main/java/com/kingsrook/qbits/edi/security/EdiClassifications.java +++ /dev/null @@ -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); - } - } -} diff --git a/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java b/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java index 38e1274..8c018c6 100644 --- a/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java +++ b/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java @@ -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)) diff --git a/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java b/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java index 8f81665..6056a55 100644 --- a/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java +++ b/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java @@ -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.of(), Map.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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java b/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java index 99c4736..cf52c2e 100644 --- a/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java +++ b/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java @@ -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.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"); diff --git a/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java b/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java index bbd02d3..5684eb2 100644 --- a/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java +++ b/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java @@ -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()) diff --git a/src/test/java/com/kingsrook/qbits/edi/InboundTest.java b/src/test/java/com/kingsrook/qbits/edi/InboundTest.java index 63c8e0f..1cf11ca 100644 --- a/src/test/java/com/kingsrook/qbits/edi/InboundTest.java +++ b/src/test/java/com/kingsrook/qbits/edi/InboundTest.java @@ -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 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 raised = new ArrayList<>(); static final List 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); } /******************************************************************************* diff --git a/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java b/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java index 200f8ff..eb24832 100644 --- a/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java +++ b/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java @@ -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 { @@ -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 outputs = new ArrayList<>(); + static final List 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. -- 2.54.0