fhir-data-utils-ts 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,10 @@ description: Complete or audit migrations, claim catalogs, converters, builders,
29
29
  projection. Do not invent resource-specific scalar aliases for indexing.
30
30
  - Keep Group membership and ResearchStudy associated parties descriptive;
31
31
  neither is a Consent, SMART scope or authorization grant.
32
+ - Keep native R5 SubscriptionTopic/Subscription configuration and notification
33
+ Bundle parsing separate from the claims-first Communication being delivered.
34
+ This package owns their reusable types and validation, never HTTP delivery,
35
+ subscriber persistence, authentication or product notification policy.
32
36
 
33
37
  ## Change with executable contracts
34
38
 
@@ -19,6 +19,12 @@ redeclare governed claim paths, code systems, UUIDs or example values.
19
19
  - Each proposal `field` names the coded target claim. Confirmation writes
20
20
  `system|code` to that target and the terminology display to `-display`; it
21
21
  must preserve the imported `-text` value.
22
+ - Review state comes only from `meta.codingProposals[].status`. An accepted
23
+ professional decision sets that proposal's `userSelected=true`; never use
24
+ `userSelected` as a draft or promotion flag.
25
+ - Group review UI with the package-owned clinical families: allergies and
26
+ intolerances, procedures, diagnostics and immunizations. Do not duplicate
27
+ resource-type lists in Vet, UHC or DataConv consumers.
22
28
  - A localized generated label may use a language-qualified `-text_<BCP47>`
23
29
  companion. It must not overwrite the imported local text before explicit
24
30
  review.
@@ -28,6 +34,12 @@ redeclare governed claim paths, code systems, UUIDs or example values.
28
34
  `entry[].data[]`.
29
35
  - Multiple imported rows may resolve to one stable UUID ResearchSubject. Keep
30
36
  every proposal on the clinical resource generated from its source row.
37
+ - Complete each ResearchSubject independently when all of its own mandatory
38
+ proposals are resolved. Other subjects in the same import thread never block
39
+ it, and review always resumes from persisted resources without re-upload.
40
+ - Text supplied by an individual, a source table or an automated/device flow
41
+ may create proposals, but does not mark an inferred code professionally
42
+ reviewed. Preserve origin/provenance separately from the review decision.
31
43
 
32
44
  ## Workflow
33
45
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 - 2026-09-23
4
+
5
+ - Add native FHIR R5 `SubscriptionTopic` and HTTPS rest-hook `Subscription`
6
+ builders plus the official resource-specific SearchParameter catalogs.
7
+ - Add a strict `subscription-notification` Bundle parser that resolves each
8
+ advertised full-resource event focus and distinguishes handshake events.
9
+ - Add the claims-first terminal-job `Communication` contract used by DataConv
10
+ and professional BFFs without introducing transport, storage or product
11
+ policy into the neutral package.
12
+
13
+ ## 0.3.3 - 2026-09-23
14
+
15
+ - Define product-neutral coding-review families and pending-proposal helpers.
16
+ - Keep review workflow in `meta.codingProposals[].status`; reserve
17
+ `userSelected=true` for a code explicitly selected by a reviewer.
18
+
3
19
  ## 0.3.2 - 2026-09-23
4
20
 
5
21
  - Preserve every coding identity from repeatable AllergyIntolerance and
package/README.md CHANGED
@@ -42,6 +42,9 @@ The root export and stable subpaths provide:
42
42
  RelatedPerson/telecom helpers and type-only DICOMDIR tree contract;
43
43
  - cross-sector terminology, Immunization graphs/reminders, and FHIR R5 Group
44
44
  and ResearchStudy contracts previously located only in SOS or Vet packages.
45
+ - native FHIR R5 SubscriptionTopic/Subscription configuration builders,
46
+ official search catalogs and a strict notification-Bundle parser; terminal
47
+ asynchronous job messages remain claims-first Communication resources.
45
48
 
46
49
  Claim catalogs and graph builders are deliberately different surfaces.
47
50
  `fhir-data-utils-ts/claims` owns canonical storage names and their specs;
@@ -111,9 +114,37 @@ The package exports builders, traversal/confirmation helpers and
111
114
  `CODING_REVIEW_EXAMPLES`. Tests and consumers reuse them instead of duplicating
112
115
  claim strings, terminology identifiers, codes or UUIDs.
113
116
 
117
+ ## R5 asynchronous notifications
118
+
119
+ `fhir-data-utils-ts/subscription-r5` separates three boundaries:
120
+
121
+ - `SubscriptionTopic` and `Subscription` are native R5 configuration resources;
122
+ - the job-completion `Communication` is canonical flat-claim business data;
123
+ - `Bundle[type=subscription-notification]` is parsed only at the receiving
124
+ rest-hook boundary.
125
+
126
+ The shared package owns types, builders, official SearchParameter metadata and
127
+ validation. DataConv owns emitting the terminal Communication, GW owns durable
128
+ subscription delivery, and each BFF owns authenticated inbox persistence and
129
+ browser/email/push fan-out. Tenant-level Communication subscriptions initially
130
+ use no server-side filter; the receiving BFF routes the minimal full resource by
131
+ its canonical `Communication.recipient` claim.
132
+
114
133
  ## Verification gate
115
134
 
116
135
  `npm run check` runs strict type checking, all contract tests and built-in
117
136
  coverage over converters, builders and core FHIR utilities. The package also
118
137
  tests every stable export subpath. Release validation additionally inspects the
119
138
  packed artifact and installs it in an isolated consumer before publication.
139
+ # Coding-review workflow
140
+
141
+ Imported wording remains in the resource's canonical `*-text` flat claim. Each
142
+ unconfirmed terminology suggestion lives beside `meta.claims` in the owning
143
+ contained resource's `meta.codingProposals[]`; it is never moved to
144
+ `ResearchSubject.meta`.
145
+
146
+ Review state comes only from `codingProposals[].status`. A Coding
147
+ `userSelected=true` flat claim records that a human selected the accepted code;
148
+ it is not a draft, publication or promotion flag. Consumers can use the shared
149
+ review families `allergies-intolerances`, `procedures`, `diagnostics` and
150
+ `immunizations` without product-specific literals.
@@ -15,6 +15,8 @@ export declare const CommunicationClaim: {
15
15
  readonly Status: "Communication.status";
16
16
  /** Stable communication identifier. */
17
17
  readonly Identifier: "Communication.identifier";
18
+ /** Request or Task that initiated this Communication. */
19
+ readonly BasedOn: "Communication.based-on";
18
20
  /** Subject identifier (typically `did:web`). */
19
21
  readonly Subject: "Communication.subject";
20
22
  /** Recipient identifier(s); CSV allowed. */
@@ -27,6 +29,8 @@ export declare const CommunicationClaim: {
27
29
  readonly NoteText: "Communication.note-text";
28
30
  /** Communication topic code (`system|code`). */
29
31
  readonly Topic: "Communication.topic";
32
+ /** Resource references that provide the Communication context; CSV allowed. */
33
+ readonly About: "Communication.about";
30
34
  /** Narrative text (short summary or body). */
31
35
  readonly Text: "Communication.text";
32
36
  /** Related resource references; CSV allowed. */
@@ -18,6 +18,8 @@ export const CommunicationClaim = {
18
18
  Status: 'Communication.status',
19
19
  /** Stable communication identifier. */
20
20
  Identifier: 'Communication.identifier',
21
+ /** Request or Task that initiated this Communication. */
22
+ BasedOn: 'Communication.based-on',
21
23
  /** Subject identifier (typically `did:web`). */
22
24
  Subject: 'Communication.subject',
23
25
  /** Recipient identifier(s); CSV allowed. */
@@ -30,6 +32,8 @@ export const CommunicationClaim = {
30
32
  NoteText: 'Communication.note-text',
31
33
  /** Communication topic code (`system|code`). */
32
34
  Topic: 'Communication.topic',
35
+ /** Resource references that provide the Communication context; CSV allowed. */
36
+ About: 'Communication.about',
33
37
  /** Narrative text (short summary or body). */
34
38
  Text: 'Communication.text',
35
39
  /** Related resource references; CSV allowed. */
@@ -13,6 +13,7 @@ export type FlatClaimCodingProposal = Readonly<{
13
13
  language: string;
14
14
  candidates: readonly FlatClaimCodingCandidate[];
15
15
  selectedCandidateId?: string;
16
+ userSelected?: boolean;
16
17
  }>;
17
18
  export type FlatClaimCodingReviewResource = Readonly<{
18
19
  resourceType: string;
@@ -43,6 +44,18 @@ export type ContainedCodingReviewItem = Readonly<{
43
44
  resource: FlatClaimCodingReviewResource;
44
45
  proposal: FlatClaimCodingProposal;
45
46
  }>;
47
+ export declare const CODING_REVIEW_CATEGORIES: Readonly<{
48
+ readonly AllergiesIntolerances: "allergies-intolerances";
49
+ readonly Procedures: "procedures";
50
+ readonly Diagnostics: "diagnostics";
51
+ readonly Immunizations: "immunizations";
52
+ readonly Other: "other";
53
+ }>;
54
+ export type CodingReviewCategory = typeof CODING_REVIEW_CATEGORIES[keyof typeof CODING_REVIEW_CATEGORIES];
55
+ /** Map a FHIR resource type to its stable, product-neutral coding-review family. */
56
+ export declare function codingReviewCategoryForResourceType(resourceType: string): CodingReviewCategory;
57
+ /** A proposal remains reviewable until a reviewer accepts, rejects or defers it. */
58
+ export declare function isCodingProposalPending(proposal: FlatClaimCodingProposal): boolean;
46
59
  /** Return the canonical local-text companion for a code-like flat claim. */
47
60
  export declare function deriveCodingLocalTextClaim(targetClaim: string): string;
48
61
  /** Return the official-display companion for a code-like flat claim. */
@@ -66,6 +79,8 @@ export declare function buildFlatClaimCodingReviewResource(input: Readonly<{
66
79
  }>): FlatClaimCodingReviewResource;
67
80
  /** Traverse every ResearchSubject entry and return resource-scoped proposals. */
68
81
  export declare function collectBatchCodingReviewItems(batch: FhirLikeCodingReviewBatch): readonly ContainedCodingReviewItem[];
82
+ /** Return only unresolved contained-resource proposals; resource userSelected is not workflow state. */
83
+ export declare function collectPendingBatchCodingReviewItems(batch: FhirLikeCodingReviewBatch): readonly ContainedCodingReviewItem[];
69
84
  /**
70
85
  * Confirm one terminology candidate on the resource that owns the proposal.
71
86
  * The coded claim and official display are added while the imported local
@@ -1,4 +1,27 @@
1
1
  import { buildFlatClaimResourceEntry } from './flat-claim-resource-graph.js';
2
+ export const CODING_REVIEW_CATEGORIES = Object.freeze({
3
+ AllergiesIntolerances: 'allergies-intolerances',
4
+ Procedures: 'procedures',
5
+ Diagnostics: 'diagnostics',
6
+ Immunizations: 'immunizations',
7
+ Other: 'other',
8
+ });
9
+ const CODING_REVIEW_RESOURCE_CATEGORIES = Object.freeze({
10
+ AllergyIntolerance: CODING_REVIEW_CATEGORIES.AllergiesIntolerances,
11
+ Procedure: CODING_REVIEW_CATEGORIES.Procedures,
12
+ Condition: CODING_REVIEW_CATEGORIES.Diagnostics,
13
+ DiagnosticReport: CODING_REVIEW_CATEGORIES.Diagnostics,
14
+ Observation: CODING_REVIEW_CATEGORIES.Diagnostics,
15
+ Immunization: CODING_REVIEW_CATEGORIES.Immunizations,
16
+ });
17
+ /** Map a FHIR resource type to its stable, product-neutral coding-review family. */
18
+ export function codingReviewCategoryForResourceType(resourceType) {
19
+ return CODING_REVIEW_RESOURCE_CATEGORIES[resourceType] ?? CODING_REVIEW_CATEGORIES.Other;
20
+ }
21
+ /** A proposal remains reviewable until a reviewer accepts, rejects or defers it. */
22
+ export function isCodingProposalPending(proposal) {
23
+ return proposal.status === 'proposed';
24
+ }
2
25
  /** Return the canonical local-text companion for a code-like flat claim. */
3
26
  export function deriveCodingLocalTextClaim(targetClaim) {
4
27
  return `${requiredCodingTarget(targetClaim)}-text`;
@@ -54,6 +77,10 @@ export function collectBatchCodingReviewItems(batch) {
54
77
  }
55
78
  return Object.freeze(items);
56
79
  }
80
+ /** Return only unresolved contained-resource proposals; resource userSelected is not workflow state. */
81
+ export function collectPendingBatchCodingReviewItems(batch) {
82
+ return Object.freeze(collectBatchCodingReviewItems(batch).filter(item => isCodingProposalPending(item.proposal)));
83
+ }
57
84
  /**
58
85
  * Confirm one terminology candidate on the resource that owns the proposal.
59
86
  * The coded claim and official display are added while the imported local
@@ -72,6 +99,7 @@ export function applyFlatClaimCodingSelection(resource, proposalId, candidateId)
72
99
  ...proposal,
73
100
  status: 'accepted',
74
101
  selectedCandidateId: candidate.id,
102
+ userSelected: true,
75
103
  });
76
104
  const updatedProposals = proposals.map((item, index) => (index === proposalIndex ? updatedProposal : item));
77
105
  return Object.freeze({
@@ -81,6 +109,7 @@ export function applyFlatClaimCodingSelection(resource, proposalId, candidateId)
81
109
  ...resource.meta.claims,
82
110
  [proposal.field]: `${candidate.system}|${candidate.code}`,
83
111
  [deriveCodingDisplayClaim(proposal.field)]: candidate.display,
112
+ [`${resource.resourceType}.userSelected`]: 'true',
84
113
  }),
85
114
  codingProposals: Object.freeze(updatedProposals),
86
115
  }),
@@ -116,6 +145,7 @@ const CONDITION_EXPECTED_ACCEPTED_CLAIMS = Object.freeze({
116
145
  ...CONDITION_EXAMPLE_RESOURCE.meta.claims,
117
146
  [CONDITION_EXAMPLE_INPUT.targetClaim]: `${CONDITION_EXAMPLE_CANDIDATE.system}|${CONDITION_EXAMPLE_CANDIDATE.code}`,
118
147
  [deriveCodingDisplayClaim(CONDITION_EXAMPLE_INPUT.targetClaim)]: CONDITION_EXAMPLE_CANDIDATE.display,
148
+ [`${CONDITION_EXAMPLE_RESOURCE.resourceType}.userSelected`]: 'true',
119
149
  });
120
150
  /** Package-owned reusable fixture; tests and consumer examples must import it. */
121
151
  export const CODING_REVIEW_EXAMPLES = Object.freeze({
@@ -9,6 +9,8 @@ export declare const FhirCodeSystems: Readonly<{
9
9
  readonly SnomedCt: "http://snomed.info/sct";
10
10
  readonly Ucum: "http://unitsofmeasure.org";
11
11
  readonly CommunicationCategory: "http://terminology.hl7.org/CodeSystem/communication-category";
12
+ readonly SubscriptionChannelType: "http://terminology.hl7.org/CodeSystem/subscription-channel-type";
13
+ readonly TaskStatus: "http://hl7.org/fhir/task-status";
12
14
  readonly ActReason: "http://terminology.hl7.org/CodeSystem/v3-ActReason";
13
15
  readonly ObservationCategory: "http://terminology.hl7.org/CodeSystem/observation-category";
14
16
  readonly EventTiming: "http://hl7.org/fhir/event-timing";
@@ -9,6 +9,8 @@ export const FhirCodeSystems = Object.freeze({
9
9
  SnomedCt: 'http://snomed.info/sct',
10
10
  Ucum: 'http://unitsofmeasure.org',
11
11
  CommunicationCategory: 'http://terminology.hl7.org/CodeSystem/communication-category',
12
+ SubscriptionChannelType: 'http://terminology.hl7.org/CodeSystem/subscription-channel-type',
13
+ TaskStatus: 'http://hl7.org/fhir/task-status',
12
14
  ActReason: 'http://terminology.hl7.org/CodeSystem/v3-ActReason',
13
15
  ObservationCategory: 'http://terminology.hl7.org/CodeSystem/observation-category',
14
16
  EventTiming: 'http://hl7.org/fhir/event-timing',
@@ -108,6 +108,8 @@ export declare const FhirResourceTypes: Readonly<{
108
108
  }>;
109
109
  /** FHIR R5-only resources currently emitted by shared subscription contracts. */
110
110
  export declare const ResourceTypesFhirR5: Readonly<{
111
+ readonly Subscription: "Subscription";
112
+ readonly SubscriptionTopic: "SubscriptionTopic";
111
113
  readonly SubscriptionStatus: "SubscriptionStatus";
112
114
  readonly AdverseEvent: "AdverseEvent";
113
115
  readonly AllergyIntolerance: "AllergyIntolerance";
@@ -61,6 +61,8 @@ export const FhirResourceTypes = ResourceTypesFhirR4;
61
61
  /** FHIR R5-only resources currently emitted by shared subscription contracts. */
62
62
  export const ResourceTypesFhirR5 = Object.freeze({
63
63
  ...ResourceTypesFhirR4,
64
+ Subscription: 'Subscription',
65
+ SubscriptionTopic: 'SubscriptionTopic',
64
66
  SubscriptionStatus: 'SubscriptionStatus',
65
67
  });
66
68
  /** FHIR complex datatypes used as discriminators inside claims-first payloads. */
package/dist/index.d.ts CHANGED
@@ -22,3 +22,4 @@ export * from './research-study-r5.js';
22
22
  export * from './fhir-tokens.js';
23
23
  export * from './related-person.js';
24
24
  export * from './imaging-types.js';
25
+ export * from './subscription-r5.js';
package/dist/index.js CHANGED
@@ -22,3 +22,4 @@ export * from './research-study-r5.js';
22
22
  export * from './fhir-tokens.js';
23
23
  export * from './related-person.js';
24
24
  export * from './imaging-types.js';
25
+ export * from './subscription-r5.js';
@@ -0,0 +1,150 @@
1
+ import type { FhirResource } from './fhir-types.js';
2
+ /** FHIR R5 version implemented by this native subscription boundary. */
3
+ export declare const SubscriptionR5Version: "5.0.0";
4
+ export declare const SubscriptionR5ChannelType: Readonly<{
5
+ readonly RestHook: "rest-hook";
6
+ }>;
7
+ export declare const SubscriptionR5Content: Readonly<{
8
+ readonly Empty: "empty";
9
+ readonly IdOnly: "id-only";
10
+ readonly FullResource: "full-resource";
11
+ }>;
12
+ export declare const SubscriptionR5Status: Readonly<{
13
+ readonly Requested: "requested";
14
+ readonly Active: "active";
15
+ readonly Error: "error";
16
+ readonly Off: "off";
17
+ readonly EnteredInError: "entered-in-error";
18
+ }>;
19
+ export declare const SubscriptionTopicR5Status: Readonly<{
20
+ readonly Draft: "draft";
21
+ readonly Active: "active";
22
+ readonly Retired: "retired";
23
+ readonly Unknown: "unknown";
24
+ }>;
25
+ export declare const SubscriptionR5NotificationType: Readonly<{
26
+ readonly Handshake: "handshake";
27
+ readonly Heartbeat: "heartbeat";
28
+ readonly EventNotification: "event-notification";
29
+ readonly QueryStatus: "query-status";
30
+ }>;
31
+ export declare const SubscriptionR5BundleType: Readonly<{
32
+ readonly Notification: "subscription-notification";
33
+ }>;
34
+ export declare const SubscriptionR5MediaType: "application/fhir+json";
35
+ export declare const FhirFlatClaimContext: Readonly<{
36
+ readonly Api: "org.hl7.fhir.api";
37
+ }>;
38
+ export declare const CommunicationStatus: Readonly<{
39
+ readonly Completed: "completed";
40
+ }>;
41
+ export declare const CommunicationCategoryCode: Readonly<{
42
+ readonly Notification: "notification";
43
+ }>;
44
+ export declare const TaskStatusCode: Readonly<{
45
+ readonly Completed: "completed";
46
+ readonly Failed: "failed";
47
+ }>;
48
+ type SearchParameterType = 'date' | 'reference' | 'string' | 'token' | 'uri';
49
+ export type R5SearchParameterDefinition = Readonly<{
50
+ code: string;
51
+ type: SearchParameterType;
52
+ expression: string;
53
+ }>;
54
+ /** Exact Subscription-specific SearchParameters published by FHIR R5 5.0.0. */
55
+ export declare const SubscriptionR5SearchParameterCatalog: readonly R5SearchParameterDefinition[];
56
+ /** Exact SubscriptionTopic-specific SearchParameters published by FHIR R5 5.0.0. */
57
+ export declare const SubscriptionTopicR5SearchParameterCatalog: readonly R5SearchParameterDefinition[];
58
+ export type SubscriptionR5Filter = Readonly<{
59
+ resourceType?: string;
60
+ filterParameter: string;
61
+ comparator?: 'eq' | 'ne';
62
+ modifier?: string;
63
+ value: string;
64
+ }>;
65
+ export type SubscriptionR5Parameter = Readonly<{
66
+ name: string;
67
+ value: string;
68
+ }>;
69
+ export type SubscriptionR5 = Readonly<{
70
+ resourceType: 'Subscription';
71
+ id: string;
72
+ status: (typeof SubscriptionR5Status)[keyof typeof SubscriptionR5Status];
73
+ topic: string;
74
+ reason?: string;
75
+ filterBy?: readonly SubscriptionR5Filter[];
76
+ channelType: Readonly<{
77
+ system: string;
78
+ code: 'rest-hook';
79
+ }>;
80
+ endpoint: string;
81
+ parameter?: readonly SubscriptionR5Parameter[];
82
+ contentType: typeof SubscriptionR5MediaType;
83
+ content: (typeof SubscriptionR5Content)[keyof typeof SubscriptionR5Content];
84
+ }>;
85
+ export type SubscriptionTopicR5 = Readonly<{
86
+ resourceType: 'SubscriptionTopic';
87
+ id: string;
88
+ url: string;
89
+ status: (typeof SubscriptionTopicR5Status)[keyof typeof SubscriptionTopicR5Status];
90
+ resourceTrigger: readonly Readonly<{
91
+ resource: string;
92
+ }>[];
93
+ canFilterBy: readonly Readonly<{
94
+ resource: string;
95
+ filterParameter: string;
96
+ comparator: readonly ('eq' | 'ne')[];
97
+ }>[];
98
+ }>;
99
+ /** Builds the native R5 topic used to notify subscribers about Communication writes. */
100
+ export declare function buildCommunicationSubscriptionTopicR5(input: Readonly<{
101
+ id: string;
102
+ url: string;
103
+ }>): SubscriptionTopicR5;
104
+ /** Builds one native R5 rest-hook Subscription. This resource is configuration, not flat clinical state. */
105
+ export declare function buildRestHookSubscriptionR5(input: Readonly<{
106
+ id: string;
107
+ topic: string;
108
+ endpoint: string;
109
+ filters?: readonly SubscriptionR5Filter[];
110
+ parameters?: readonly SubscriptionR5Parameter[];
111
+ content?: SubscriptionR5['content'];
112
+ reason?: string;
113
+ }>): SubscriptionR5;
114
+ export type TerminalJobStatusCode = (typeof TaskStatusCode)[keyof typeof TaskStatusCode];
115
+ /**
116
+ * Builds the canonical claims-first Communication emitted when an asynchronous
117
+ * Task reaches a terminal state. `Communication.status=completed` describes
118
+ * the sent message; `Communication.content-code` carries the linked Task state.
119
+ */
120
+ export declare function buildTerminalJobCommunication(input: Readonly<{
121
+ id: string;
122
+ threadId: string;
123
+ jobId: string;
124
+ jobStatus: TerminalJobStatusCode;
125
+ recipient: string;
126
+ sender: string;
127
+ researchStudyReference: string;
128
+ sent: string;
129
+ }>): FhirResource & Readonly<{
130
+ resourceType: 'Communication';
131
+ id: string;
132
+ meta: Readonly<{
133
+ claims: Readonly<Record<string, string>>;
134
+ }>;
135
+ }>;
136
+ export type ParsedSubscriptionNotificationEventR5 = Readonly<{
137
+ eventNumber: string;
138
+ timestamp?: string;
139
+ focusReference: string;
140
+ focusResource: FhirResource;
141
+ }>;
142
+ export type ParsedSubscriptionNotificationR5 = Readonly<{
143
+ type: (typeof SubscriptionR5NotificationType)[keyof typeof SubscriptionR5NotificationType];
144
+ subscriptionReference: string;
145
+ topic?: string;
146
+ events: readonly ParsedSubscriptionNotificationEventR5[];
147
+ }>;
148
+ /** Validates a standard R5 notification Bundle and resolves every advertised full-resource focus. */
149
+ export declare function parseSubscriptionNotificationR5(input: unknown): ParsedSubscriptionNotificationR5;
150
+ export {};
@@ -0,0 +1,202 @@
1
+ import { CommunicationClaim } from './claims/communication-claims.js';
2
+ import { FhirCodeSystems } from './constants/fhir-code-systems.js';
3
+ import { ResourceTypesFhirR5 } from './constants/fhir-resource-types.js';
4
+ /** FHIR R5 version implemented by this native subscription boundary. */
5
+ export const SubscriptionR5Version = '5.0.0';
6
+ export const SubscriptionR5ChannelType = Object.freeze({ RestHook: 'rest-hook' });
7
+ export const SubscriptionR5Content = Object.freeze({
8
+ Empty: 'empty',
9
+ IdOnly: 'id-only',
10
+ FullResource: 'full-resource',
11
+ });
12
+ export const SubscriptionR5Status = Object.freeze({
13
+ Requested: 'requested',
14
+ Active: 'active',
15
+ Error: 'error',
16
+ Off: 'off',
17
+ EnteredInError: 'entered-in-error',
18
+ });
19
+ export const SubscriptionTopicR5Status = Object.freeze({
20
+ Draft: 'draft',
21
+ Active: 'active',
22
+ Retired: 'retired',
23
+ Unknown: 'unknown',
24
+ });
25
+ export const SubscriptionR5NotificationType = Object.freeze({
26
+ Handshake: 'handshake',
27
+ Heartbeat: 'heartbeat',
28
+ EventNotification: 'event-notification',
29
+ QueryStatus: 'query-status',
30
+ });
31
+ export const SubscriptionR5BundleType = Object.freeze({ Notification: 'subscription-notification' });
32
+ export const SubscriptionR5MediaType = 'application/fhir+json';
33
+ export const FhirFlatClaimContext = Object.freeze({ Api: 'org.hl7.fhir.api' });
34
+ export const CommunicationStatus = Object.freeze({ Completed: 'completed' });
35
+ export const CommunicationCategoryCode = Object.freeze({ Notification: 'notification' });
36
+ export const TaskStatusCode = Object.freeze({ Completed: 'completed', Failed: 'failed' });
37
+ const searchParameter = (code, type, expression) => Object.freeze({ code, type, expression });
38
+ /** Exact Subscription-specific SearchParameters published by FHIR R5 5.0.0. */
39
+ export const SubscriptionR5SearchParameterCatalog = Object.freeze([
40
+ searchParameter('contact', 'token', 'Subscription.contact'),
41
+ searchParameter('content-level', 'token', 'Subscription.content'),
42
+ searchParameter('filter-value', 'string', 'Subscription.filterBy.value'),
43
+ searchParameter('identifier', 'token', 'Subscription.identifier'),
44
+ searchParameter('name', 'string', 'Subscription.name'),
45
+ searchParameter('owner', 'reference', 'Subscription.managingEntity'),
46
+ searchParameter('payload', 'token', 'Subscription.contentType'),
47
+ searchParameter('status', 'token', 'Subscription.status'),
48
+ searchParameter('topic', 'uri', 'Subscription.topic'),
49
+ searchParameter('type', 'token', 'Subscription.channelType'),
50
+ searchParameter('url', 'uri', 'Subscription.endpoint'),
51
+ ]);
52
+ /** Exact SubscriptionTopic-specific SearchParameters published by FHIR R5 5.0.0. */
53
+ export const SubscriptionTopicR5SearchParameterCatalog = Object.freeze([
54
+ searchParameter('date', 'date', 'SubscriptionTopic.date'),
55
+ searchParameter('derived-or-self', 'uri', 'SubscriptionTopic.url | SubscriptionTopic.derivedFrom'),
56
+ searchParameter('effective', 'date', 'SubscriptionTopic.effectivePeriod'),
57
+ searchParameter('event', 'token', 'SubscriptionTopic.eventTrigger.event'),
58
+ searchParameter('identifier', 'token', 'SubscriptionTopic.identifier'),
59
+ searchParameter('resource', 'uri', 'SubscriptionTopic.resourceTrigger.resource | SubscriptionTopic.eventTrigger.resource | SubscriptionTopic.canFilterBy.resource | SubscriptionTopic.notificationShape.resource'),
60
+ searchParameter('status', 'token', 'SubscriptionTopic.status'),
61
+ searchParameter('title', 'string', 'SubscriptionTopic.title'),
62
+ searchParameter('trigger-description', 'string', 'SubscriptionTopic.resourceTrigger.description'),
63
+ searchParameter('url', 'uri', 'SubscriptionTopic.url'),
64
+ searchParameter('version', 'token', 'SubscriptionTopic.version'),
65
+ ]);
66
+ /** Builds the native R5 topic used to notify subscribers about Communication writes. */
67
+ export function buildCommunicationSubscriptionTopicR5(input) {
68
+ return Object.freeze({
69
+ resourceType: ResourceTypesFhirR5.SubscriptionTopic,
70
+ id: required(input.id, 'subscription_topic_id_required'),
71
+ url: absoluteHttpsUrl(input.url, 'subscription_topic_url_invalid'),
72
+ status: SubscriptionTopicR5Status.Active,
73
+ resourceTrigger: Object.freeze([{ resource: ResourceTypesFhirR5.Communication }]),
74
+ canFilterBy: Object.freeze([
75
+ Object.freeze({ resource: ResourceTypesFhirR5.Communication, filterParameter: 'identifier', comparator: Object.freeze(['eq', 'ne']) }),
76
+ Object.freeze({ resource: ResourceTypesFhirR5.Communication, filterParameter: 'recipient', comparator: Object.freeze(['eq', 'ne']) }),
77
+ Object.freeze({ resource: ResourceTypesFhirR5.Communication, filterParameter: 'status', comparator: Object.freeze(['eq', 'ne']) }),
78
+ ]),
79
+ });
80
+ }
81
+ /** Builds one native R5 rest-hook Subscription. This resource is configuration, not flat clinical state. */
82
+ export function buildRestHookSubscriptionR5(input) {
83
+ const filters = Object.freeze((input.filters ?? []).map(filter => Object.freeze({
84
+ ...(filter.resourceType ? { resourceType: required(filter.resourceType, 'subscription_filter_resource_required') } : {}),
85
+ filterParameter: required(filter.filterParameter, 'subscription_filter_parameter_required'),
86
+ ...(filter.comparator ? { comparator: filter.comparator } : {}),
87
+ ...(filter.modifier ? { modifier: filter.modifier } : {}),
88
+ value: required(filter.value, 'subscription_filter_value_required'),
89
+ })));
90
+ const parameters = Object.freeze((input.parameters ?? []).map(parameter => Object.freeze({
91
+ name: required(parameter.name, 'subscription_parameter_name_required'),
92
+ value: required(parameter.value, 'subscription_parameter_value_required'),
93
+ })));
94
+ return Object.freeze({
95
+ resourceType: ResourceTypesFhirR5.Subscription,
96
+ id: required(input.id, 'subscription_id_required'),
97
+ status: SubscriptionR5Status.Requested,
98
+ topic: absoluteHttpsUrl(input.topic, 'subscription_topic_url_invalid'),
99
+ ...(input.reason?.trim() ? { reason: input.reason.trim() } : {}),
100
+ ...(filters.length ? { filterBy: filters } : {}),
101
+ channelType: Object.freeze({
102
+ system: FhirCodeSystems.SubscriptionChannelType,
103
+ code: SubscriptionR5ChannelType.RestHook,
104
+ }),
105
+ endpoint: absoluteHttpsUrl(input.endpoint, 'subscription_endpoint_invalid'),
106
+ ...(parameters.length ? { parameter: parameters } : {}),
107
+ contentType: SubscriptionR5MediaType,
108
+ content: input.content ?? SubscriptionR5Content.FullResource,
109
+ });
110
+ }
111
+ /**
112
+ * Builds the canonical claims-first Communication emitted when an asynchronous
113
+ * Task reaches a terminal state. `Communication.status=completed` describes
114
+ * the sent message; `Communication.content-code` carries the linked Task state.
115
+ */
116
+ export function buildTerminalJobCommunication(input) {
117
+ if (!Object.values(TaskStatusCode).includes(input.jobStatus))
118
+ throw new TypeError('terminal_job_status_invalid');
119
+ const sent = required(input.sent, 'terminal_job_sent_required');
120
+ if (Number.isNaN(Date.parse(sent)))
121
+ throw new TypeError('terminal_job_sent_invalid');
122
+ return Object.freeze({
123
+ resourceType: ResourceTypesFhirR5.Communication,
124
+ id: required(input.id, 'terminal_job_communication_id_required'),
125
+ meta: Object.freeze({ claims: Object.freeze({
126
+ '@context': FhirFlatClaimContext.Api,
127
+ [CommunicationClaim.Identifier]: required(input.threadId, 'terminal_job_thread_required'),
128
+ [CommunicationClaim.Status]: CommunicationStatus.Completed,
129
+ [CommunicationClaim.Category]: `${FhirCodeSystems.CommunicationCategory}|${CommunicationCategoryCode.Notification}`,
130
+ [CommunicationClaim.Recipient]: required(input.recipient, 'terminal_job_recipient_required'),
131
+ [CommunicationClaim.Sender]: required(input.sender, 'terminal_job_sender_required'),
132
+ [CommunicationClaim.Sent]: sent,
133
+ [CommunicationClaim.Subject]: required(input.researchStudyReference, 'terminal_job_study_required'),
134
+ [CommunicationClaim.ContentReference]: `${ResourceTypesFhirR5.Task}/${required(input.jobId, 'terminal_job_id_required')}`,
135
+ [CommunicationClaim.ContentCode]: `${FhirCodeSystems.TaskStatus}|${input.jobStatus}`,
136
+ }) }),
137
+ });
138
+ }
139
+ /** Validates a standard R5 notification Bundle and resolves every advertised full-resource focus. */
140
+ export function parseSubscriptionNotificationR5(input) {
141
+ const bundle = record(input);
142
+ if (bundle.resourceType !== ResourceTypesFhirR5.Bundle || bundle.type !== SubscriptionR5BundleType.Notification) {
143
+ throw new TypeError('subscription_notification_bundle_invalid');
144
+ }
145
+ const entries = Array.isArray(bundle.entry) ? bundle.entry : [];
146
+ if (entries.length < 1 || entries.length > 101)
147
+ throw new TypeError('subscription_notification_entries_invalid');
148
+ const resources = entries.map(entry => record(record(entry).resource)).filter(resource => resource.resourceType);
149
+ const status = resources.find(resource => resource.resourceType === ResourceTypesFhirR5.SubscriptionStatus);
150
+ if (!status)
151
+ throw new TypeError('subscription_notification_status_missing');
152
+ const notificationType = required(String(status.type ?? ''), 'subscription_notification_type_missing');
153
+ if (!Object.values(SubscriptionR5NotificationType).includes(notificationType)) {
154
+ throw new TypeError('subscription_notification_type_invalid');
155
+ }
156
+ const subscriptionReference = required(String(record(status.subscription).reference ?? ''), 'subscription_notification_subscription_missing');
157
+ const rawEvents = Array.isArray(status.notificationEvent) ? status.notificationEvent : [];
158
+ if (notificationType === SubscriptionR5NotificationType.EventNotification && rawEvents.length < 1) {
159
+ throw new TypeError('subscription_notification_event_missing');
160
+ }
161
+ const events = rawEvents.map(rawEvent => {
162
+ const event = record(rawEvent);
163
+ const focusReference = required(String(record(event.focus).reference ?? ''), 'subscription_notification_focus_missing');
164
+ const focusResource = resources.find(resource => `${resource.resourceType}/${resource.id ?? ''}` === focusReference);
165
+ if (!focusResource)
166
+ throw new TypeError('subscription_notification_focus_missing');
167
+ return Object.freeze({
168
+ eventNumber: required(String(event.eventNumber ?? ''), 'subscription_notification_event_number_missing'),
169
+ ...(event.timestamp ? { timestamp: String(event.timestamp) } : {}),
170
+ focusReference,
171
+ focusResource: focusResource,
172
+ });
173
+ });
174
+ return Object.freeze({
175
+ type: notificationType,
176
+ subscriptionReference,
177
+ ...(status.topic ? { topic: String(status.topic) } : {}),
178
+ events: Object.freeze(events),
179
+ });
180
+ }
181
+ function absoluteHttpsUrl(value, error) {
182
+ const text = required(value, error);
183
+ let url;
184
+ try {
185
+ url = new URL(text);
186
+ }
187
+ catch {
188
+ throw new TypeError(error);
189
+ }
190
+ if (url.protocol !== 'https:')
191
+ throw new TypeError(error);
192
+ return url.href;
193
+ }
194
+ function required(value, error) {
195
+ const text = String(value ?? '').trim();
196
+ if (!text)
197
+ throw new TypeError(error);
198
+ return text;
199
+ }
200
+ function record(value) {
201
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
202
+ }
@@ -109,7 +109,22 @@ family for every assessment scale.
109
109
  | Preserve an obsolete input | Explicit read-only compatibility adapter |
110
110
  | Add transport, SMART or persistence behavior | Owning SDK/service, never this package |
111
111
  | Add sector terminology or policy | SOS, Vet or UHC data package |
112
+ | Configure an R5 SubscriptionTopic/Subscription | Native builder and official search catalog in `subscription-r5`; registration remains in the owning service |
113
+ | Persist or deliver a subscription event | GW/BFF transport and storage, never this package |
112
114
 
113
115
  Converters and builders import catalog symbols. Tests may spell native FHIR
114
116
  wire fields, but do not duplicate flat-claim paths except when proving that a
115
117
  malformed or invented path is rejected.
118
+
119
+ ## Subscription notification boundary
120
+
121
+ FHIR R5 `SubscriptionTopic` and `Subscription` are native configuration
122
+ resources, not flat clinical persistence. This package owns their strict typed
123
+ builders, the official R5 resource-specific SearchParameter catalogs and the
124
+ parser for `Bundle[type=subscription-notification]`. It also owns the minimal
125
+ claims-first terminal Task `Communication` shape so producers and receivers do
126
+ not duplicate claim paths.
127
+
128
+ HTTP delivery, authentication, durable outbox/inbox storage and browser or
129
+ email/push fan-out remain in GW, DataConv and the product BFFs. The neutral
130
+ package contains no HTTP client and no gateway policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhir-data-utils-ts",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "FHIR-version-neutral flat-claim and SearchParameter data utilities",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -107,6 +107,10 @@
107
107
  "types": "./dist/imaging-types.d.ts",
108
108
  "default": "./dist/imaging-types.js"
109
109
  },
110
+ "./subscription-r5": {
111
+ "types": "./dist/subscription-r5.d.ts",
112
+ "default": "./dist/subscription-r5.js"
113
+ },
110
114
  "./constants": {
111
115
  "types": "./dist/constants/index.d.ts",
112
116
  "default": "./dist/constants/index.js"