@company-semantics/contracts 33.0.0 → 35.0.0
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.
- package/package.json +4 -4
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +2 -2
- package/src/index.ts +17 -11
- package/src/notifications/README.md +140 -0
- package/src/notifications/__tests__/README.md +54 -0
- package/src/notifications/__tests__/__snapshots__/README.md +32 -0
- package/src/notifications/__tests__/content.test.ts +186 -0
- package/src/notifications/__tests__/context.test.ts +72 -0
- package/src/notifications/__tests__/definition.test.ts +222 -0
- package/src/notifications/__tests__/kinds.test.ts +81 -0
- package/src/notifications/__tests__/output-parity.golden.ts +363 -0
- package/src/notifications/__tests__/output-parity.test.ts +122 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
- package/src/notifications/__tests__/renderer.test.ts +181 -0
- package/src/notifications/content.ts +249 -0
- package/src/notifications/context.ts +70 -0
- package/src/notifications/definition.ts +82 -0
- package/src/notifications/index.ts +104 -0
- package/src/notifications/kinds/README.md +57 -0
- package/src/notifications/kinds/auth-otp.ts +99 -0
- package/src/notifications/kinds/chat-shared.ts +56 -0
- package/src/notifications/kinds/company-md-access-approved.ts +57 -0
- package/src/notifications/kinds/company-md-access-denied.ts +61 -0
- package/src/notifications/kinds/company-md-access-requested.ts +65 -0
- package/src/notifications/kinds/index.ts +19 -0
- package/src/notifications/kinds/org-invite.ts +62 -0
- package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
- package/src/notifications/kinds/ownership-transfer.ts +68 -0
- package/src/notifications/kinds/security-alert.ts +78 -0
- package/src/notifications/kinds/share-granted.ts +74 -0
- package/src/notifications/kinds/unit-owner-granted.ts +95 -0
- package/src/notifications/kinds.ts +68 -0
- package/src/{email/types.ts → notifications/payloads.ts} +33 -68
- package/src/notifications/registry.ts +107 -0
- package/src/notifications/render.ts +106 -0
- package/src/notifications/renderer.ts +50 -0
- package/src/notifications/renderers/README.md +50 -0
- package/src/notifications/renderers/email/README.md +74 -0
- package/src/notifications/renderers/email/__tests__/README.md +29 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
- package/src/notifications/renderers/email/constants.ts +47 -0
- package/src/notifications/renderers/email/cta.ts +63 -0
- package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
- package/src/notifications/renderers/email/index.ts +73 -0
- package/src/notifications/renderers/email/render.ts +225 -0
- package/src/notifications/renderers/email/shells.ts +60 -0
- package/src/notifications/renderers/slack/README.md +73 -0
- package/src/notifications/renderers/slack/__tests__/README.md +33 -0
- package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
- package/src/notifications/renderers/slack/index.ts +261 -0
- package/src/notifications/renderers/sms/README.md +69 -0
- package/src/notifications/renderers/sms/__tests__/README.md +31 -0
- package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
- package/src/notifications/renderers/sms/index.ts +131 -0
- package/src/notifications/text.ts +52 -0
- package/src/email/README.md +0 -51
- package/src/email/__tests__/registry.test.ts +0 -161
- package/src/email/index.ts +0 -36
- package/src/email/registry.ts +0 -155
- package/src/email/render/auth-otp.ts +0 -71
- package/src/email/render/blocks.ts +0 -281
- package/src/email/render/chat-shared.ts +0 -35
- package/src/email/render/company-md-access-approved.ts +0 -42
- package/src/email/render/company-md-access-denied.ts +0 -43
- package/src/email/render/company-md-access-requested.ts +0 -46
- package/src/email/render/constants.ts +0 -18
- package/src/email/render/index.ts +0 -58
- package/src/email/render/org-invite.ts +0 -40
- package/src/email/render/ownership-transfer-completed.ts +0 -41
- package/src/email/render/ownership-transfer.ts +0 -42
- package/src/email/render/render-email.ts +0 -194
- package/src/email/render/security-alert.ts +0 -61
- package/src/email/render/share-granted.ts +0 -52
- package/src/email/render/unit-owner-granted.ts +0 -60
- /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-agnostic notification content — the semantic model (ADR-CONTRACTS-086).
|
|
3
|
+
*
|
|
4
|
+
* `NotificationContent` is what a notification SAYS. It carries no markup, no
|
|
5
|
+
* styling and no channel names: every element names a communicative intent
|
|
6
|
+
* ("greet the recipient", "these are the facts", "this is the one action"), and
|
|
7
|
+
* a `Renderer` decides what that intent looks like on its channel. This is the
|
|
8
|
+
* old `email/render/blocks` rule — components own the styling, callers supply
|
|
9
|
+
* only content — lifted one level: the renderer owns the styling, `compose`
|
|
10
|
+
* supplies only content.
|
|
11
|
+
*
|
|
12
|
+
* The element union is deliberately FLAT. Elements do not nest into layout
|
|
13
|
+
* containers, because layout is a channel's answer rather than the
|
|
14
|
+
* notification's. `Section` groups elements a channel may want to keep together
|
|
15
|
+
* (one email body, one chat message, one push bundle) and is the only grouping
|
|
16
|
+
* this model commits to.
|
|
17
|
+
*
|
|
18
|
+
* INVARIANTS:
|
|
19
|
+
* - Types only — no markup, no styling fields, no channel names on elements.
|
|
20
|
+
* - Every element is content a renderer MAY decline to show. Capability lives on
|
|
21
|
+
* the renderer (`supports`), never on the element.
|
|
22
|
+
* - Element names describe intent, not appearance. `Warning` is "this is a
|
|
23
|
+
* security notice", not "render a red box".
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Shared value types
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
/** One `label` / `value` fact. The renderer owns the separator and alignment. */
|
|
31
|
+
export interface KeyValueRow {
|
|
32
|
+
label: string;
|
|
33
|
+
value: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Elements
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Address the recipient. `recipientName` absent means the name is unknown — the
|
|
42
|
+
* renderer supplies its own generic form rather than the caller inventing one.
|
|
43
|
+
*/
|
|
44
|
+
export interface Greeting {
|
|
45
|
+
type: "greeting";
|
|
46
|
+
recipientName?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A prose paragraph — the notification's own voice.
|
|
51
|
+
*
|
|
52
|
+
* A newline separates lines WITHIN one paragraph: two sentences that read as one
|
|
53
|
+
* thought. Two paragraphs are two `body` elements. The distinction is content,
|
|
54
|
+
* not styling — which sentences belong together is the notification's to say,
|
|
55
|
+
* and only how tightly they sit is the renderer's.
|
|
56
|
+
*/
|
|
57
|
+
export interface Body {
|
|
58
|
+
type: "body";
|
|
59
|
+
text: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The notification's primary facts, as one group. Grouping is the point: a
|
|
64
|
+
* channel that renders facts as a real table (or a Slack field block) needs them
|
|
65
|
+
* together, which per-row elements could not express.
|
|
66
|
+
*/
|
|
67
|
+
export interface KeyValueTable {
|
|
68
|
+
type: "keyValueTable";
|
|
69
|
+
rows: KeyValueRow[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The one action the notification is asking for. `href` absent means the label
|
|
74
|
+
* IS the payload (an OTP code), not a link — the renderer must not fabricate a
|
|
75
|
+
* destination for it.
|
|
76
|
+
*/
|
|
77
|
+
export interface CallToAction {
|
|
78
|
+
type: "callToAction";
|
|
79
|
+
label: string;
|
|
80
|
+
href?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A sequence of items. `ordered` when the sequence itself carries meaning. */
|
|
84
|
+
export interface List {
|
|
85
|
+
type: "list";
|
|
86
|
+
items: string[];
|
|
87
|
+
ordered?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A thematic break between elements. Carries no content by design. */
|
|
91
|
+
export interface Divider {
|
|
92
|
+
type: "divider";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Marks the notification as a security notice. Fieldless on purpose: the banner
|
|
97
|
+
* IS the content, and its wording and prominence belong to the renderer — the
|
|
98
|
+
* same reason `security()` in the old `email/render/blocks` took no arguments.
|
|
99
|
+
*/
|
|
100
|
+
export interface Warning {
|
|
101
|
+
type: "warning";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Secondary context ABOUT the request that produced this notification (request
|
|
106
|
+
* IP, device), as distinct from `KeyValueTable`, which carries the notification's
|
|
107
|
+
* own subject matter. The split is what lets a channel de-emphasise or drop
|
|
108
|
+
* diagnostic detail while still showing the facts that matter.
|
|
109
|
+
*
|
|
110
|
+
* Whether these rows exist at all is `compose`'s decision, not a renderer's —
|
|
111
|
+
* some of this is PII and is gated upstream.
|
|
112
|
+
*/
|
|
113
|
+
export interface MetadataElement {
|
|
114
|
+
type: "metadata";
|
|
115
|
+
/** Heading above the rows, e.g. "Request details". */
|
|
116
|
+
label?: string;
|
|
117
|
+
rows: KeyValueRow[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The closing legal / reassurance lines ("If you weren't expecting this, no
|
|
122
|
+
* action is required."). `lines` are rendered as one unit; the renderer chooses
|
|
123
|
+
* how to separate them.
|
|
124
|
+
*/
|
|
125
|
+
export interface Notice {
|
|
126
|
+
type: "notice";
|
|
127
|
+
lines: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The trailing sign-off. `signer` overrides the brand name from `RenderContext`
|
|
132
|
+
* for a notification sent on someone else's behalf; absent means the context's
|
|
133
|
+
* brand signs it. The copyright year comes from the context, never from a clock
|
|
134
|
+
* read inside the renderer.
|
|
135
|
+
*/
|
|
136
|
+
export interface Signature {
|
|
137
|
+
type: "signature";
|
|
138
|
+
signer?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A leading image. `alt` is required — a text-only channel has nothing else. */
|
|
142
|
+
export interface HeroImage {
|
|
143
|
+
type: "heroImage";
|
|
144
|
+
src: string;
|
|
145
|
+
alt: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// =============================================================================
|
|
149
|
+
// Chat unit
|
|
150
|
+
// =============================================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One turn in a reproduced conversation. `from` attributes a user's message.
|
|
154
|
+
*
|
|
155
|
+
* Named `ChatTurn`, not `ChatMessage`, because the old `email/render/blocks`
|
|
156
|
+
* exported a `ChatMessage` and the email renderer imported both at once. That
|
|
157
|
+
* collision died with the directory; the name stays because it is the better one
|
|
158
|
+
* — a turn is what this IS, and renaming a published type to reclaim a freed
|
|
159
|
+
* name is churn.
|
|
160
|
+
*/
|
|
161
|
+
export interface ChatTurn {
|
|
162
|
+
type: "message";
|
|
163
|
+
role: "user" | "assistant";
|
|
164
|
+
text: string;
|
|
165
|
+
from?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** "The conversation continues" — an elision between turns. */
|
|
169
|
+
export interface ChatContinuation {
|
|
170
|
+
type: "continuation";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* An item inside a chat unit. A call to action inside a conversation is the same
|
|
175
|
+
* intent as a standalone one, so `CallToAction` is reused rather than mirrored.
|
|
176
|
+
*/
|
|
177
|
+
export type ChatUnitItem = ChatTurn | CallToAction | ChatContinuation;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A reproduced conversation, shown as a unit. Renderers that cannot depict a
|
|
181
|
+
* conversation should decline the whole element rather than flatten it — the
|
|
182
|
+
* turn-taking is the meaning.
|
|
183
|
+
*/
|
|
184
|
+
export interface ChatUnit {
|
|
185
|
+
type: "chatUnit";
|
|
186
|
+
items: ChatUnitItem[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// =============================================================================
|
|
190
|
+
// The element union
|
|
191
|
+
// =============================================================================
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Everything a notification can say, flat. Named `NotificationElement` rather
|
|
195
|
+
* than `Element` because `Element` is a DOM global — a bare export would shadow
|
|
196
|
+
* it for every consumer that imports this barrel.
|
|
197
|
+
*/
|
|
198
|
+
export type NotificationElement =
|
|
199
|
+
| Greeting
|
|
200
|
+
| Body
|
|
201
|
+
| KeyValueTable
|
|
202
|
+
| CallToAction
|
|
203
|
+
| List
|
|
204
|
+
| Divider
|
|
205
|
+
| Warning
|
|
206
|
+
| MetadataElement
|
|
207
|
+
| Notice
|
|
208
|
+
| ChatUnit
|
|
209
|
+
| Signature
|
|
210
|
+
| HeroImage;
|
|
211
|
+
|
|
212
|
+
/** The discriminant of `NotificationElement` — what `supports` is asked about. */
|
|
213
|
+
export type NotificationElementType = NotificationElement["type"];
|
|
214
|
+
|
|
215
|
+
// =============================================================================
|
|
216
|
+
// Content
|
|
217
|
+
// =============================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A run of elements a channel should keep together. Sections are the seam at
|
|
221
|
+
* which a channel MAY split one notification into several deliverables (three
|
|
222
|
+
* chat messages, one email body); a renderer is free to ignore them.
|
|
223
|
+
*/
|
|
224
|
+
export interface Section {
|
|
225
|
+
elements: NotificationElement[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Notification-level facts that are not body content.
|
|
230
|
+
*
|
|
231
|
+
* `title` is the headline, held here rather than in a channel's registry — that
|
|
232
|
+
* fusion (subject in `EMAIL_KINDS`, copy in the template) is exactly what
|
|
233
|
+
* ADR-CONTRACTS-086 splits. Email renders it as the subject; another channel
|
|
234
|
+
* renders it as a notification title, or drops it.
|
|
235
|
+
*
|
|
236
|
+
* Distinct from the `metadata` ELEMENT, which is body content about the
|
|
237
|
+
* originating request.
|
|
238
|
+
*/
|
|
239
|
+
export interface NotificationMetadata {
|
|
240
|
+
/** The notification kind this content was composed from, e.g. "org.invite". */
|
|
241
|
+
kind: string;
|
|
242
|
+
title: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** One notification, said once, for every channel. */
|
|
246
|
+
export interface NotificationContent {
|
|
247
|
+
metadata: NotificationMetadata;
|
|
248
|
+
sections: Section[];
|
|
249
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient render state — the things a notification's copy depends on but does
|
|
3
|
+
* not carry (ADR-CONTRACTS-086).
|
|
4
|
+
*
|
|
5
|
+
* `RenderContext` exists to remove a hidden `now`. The email layer's
|
|
6
|
+
* `signature()` read `new Date().getFullYear()` mid-render: the one conceded
|
|
7
|
+
* impurity in this package, and the reason every render test had to freeze the
|
|
8
|
+
* clock. Hoisting the year into an explicit context makes the clock read a
|
|
9
|
+
* single, visible act at the edge — `createRenderContext()` — and leaves
|
|
10
|
+
* rendering itself a pure function of (content, context).
|
|
11
|
+
*
|
|
12
|
+
* The defaults reproduce the values that layer rendered (`COMPANY_NAME` + the
|
|
13
|
+
* current year), so output is unchanged; what changes is that a caller can now
|
|
14
|
+
* pin them.
|
|
15
|
+
*
|
|
16
|
+
* INVARIANTS:
|
|
17
|
+
* - Renderers MUST read the brand and the year from here, never from a clock or
|
|
18
|
+
* a constant of their own. That is the whole point of the type.
|
|
19
|
+
* - `createRenderContext()` is the ONLY thing in this domain permitted to read
|
|
20
|
+
* the clock, and only to default `copyrightYear`. Pass `copyrightYear` to get
|
|
21
|
+
* a fully deterministic context.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The product name a notification signs off with, absent a caller's own.
|
|
26
|
+
*
|
|
27
|
+
* It lives here rather than in the email channel because it is the default for
|
|
28
|
+
* `Brand.name` — the same answer on every channel — and a channel-agnostic
|
|
29
|
+
* default sourced from a channel is how the seam ADR-CONTRACTS-086 draws leaks
|
|
30
|
+
* back. Renderers read `context.brand.name`, never this.
|
|
31
|
+
*/
|
|
32
|
+
export const COMPANY_NAME = "Company Semantics";
|
|
33
|
+
|
|
34
|
+
/** Who the notification is from, as the recipient sees it. */
|
|
35
|
+
export interface Brand {
|
|
36
|
+
/** Product name in signatures and footers. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** Year in the copyright line. Supplied, never read from a clock at render. */
|
|
39
|
+
copyrightYear: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ambient state every renderer resolves against. */
|
|
43
|
+
export interface RenderContext {
|
|
44
|
+
brand: Brand;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Per-field overrides for `createRenderContext`. */
|
|
48
|
+
export interface RenderContextOverrides {
|
|
49
|
+
brand?: Partial<Brand>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a `RenderContext`, defaulting to `COMPANY_NAME` and the current year, so
|
|
54
|
+
* an un-overridden context renders exactly what the email layer rendered before
|
|
55
|
+
* the year was hoisted out of it.
|
|
56
|
+
*
|
|
57
|
+
* This is the one clock read in the domain. Tests and any caller wanting
|
|
58
|
+
* reproducible bytes should pass `copyrightYear`.
|
|
59
|
+
*/
|
|
60
|
+
export function createRenderContext(
|
|
61
|
+
overrides?: RenderContextOverrides,
|
|
62
|
+
): RenderContext {
|
|
63
|
+
return {
|
|
64
|
+
brand: {
|
|
65
|
+
name: overrides?.brand?.name ?? COMPANY_NAME,
|
|
66
|
+
copyrightYear:
|
|
67
|
+
overrides?.brand?.copyrightYear ?? new Date().getFullYear(),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one notification kind IS (ADR-CONTRACTS-085).
|
|
3
|
+
*
|
|
4
|
+
* A `NotificationDefinition` is the whole of a kind: given its facts and the
|
|
5
|
+
* ambient state, it says what the notification says. `compose` is the seam —
|
|
6
|
+
* everything above it is business fact, everything below it is one channel's
|
|
7
|
+
* opinion about how to draw a sentence.
|
|
8
|
+
*
|
|
9
|
+
* This replaced `EmailKindDefinition` (deleted with `src/email/`,
|
|
10
|
+
* ADR-CONTRACTS-086), and what it DROPS is the point:
|
|
11
|
+
*
|
|
12
|
+
* - `subject` is gone. A subject is a title, and a title is content, so it is
|
|
13
|
+
* `compose`'s output (`metadata.title`) rather than a registry field. That
|
|
14
|
+
* fusion — the headline in `EMAIL_KINDS`, the body in a template function, the
|
|
15
|
+
* two interpolated by different code — is what ADR-CONTRACTS-086 set out to
|
|
16
|
+
* split. It also retires `renderEmail`'s `resolveSubject`/`subjectData` pair:
|
|
17
|
+
* `{orgName}` string substitution against an untyped bag becomes an ordinary
|
|
18
|
+
* template literal that the compiler checks.
|
|
19
|
+
* - `plainTextRequired` / `htmlSupported` are gone, superseded by
|
|
20
|
+
* `Renderer.supports`. A kind is not what can or cannot draw an image — that
|
|
21
|
+
* is a property of where it is going, and it belongs on the renderer.
|
|
22
|
+
*
|
|
23
|
+
* There is deliberately no field naming where a notification may be delivered.
|
|
24
|
+
* Capability is asked of the renderer, never declared on the kind: tagging a
|
|
25
|
+
* definition with its destinations puts knowledge of the channel back inside the
|
|
26
|
+
* thing that exists to be independent of it, and makes every new surface a
|
|
27
|
+
* rewrite of every definition.
|
|
28
|
+
*
|
|
29
|
+
* INVARIANTS:
|
|
30
|
+
* - `compose` is PURE — a function of `(payload, context)` and nothing else. No
|
|
31
|
+
* clock (the year comes from `context.brand`), no environment, no I/O, no
|
|
32
|
+
* ambient constant of its own.
|
|
33
|
+
* - `compose` emits content only. No markup, no styling, no channel names. If a
|
|
34
|
+
* value is a formatting choice a channel could reasonably make differently, it
|
|
35
|
+
* is the renderer's, not `compose`'s.
|
|
36
|
+
* - `kind` MUST equal the key this definition sits under in `./registry`.
|
|
37
|
+
* - Whether PII-bearing elements exist at all is `compose`'s decision, taken from
|
|
38
|
+
* what the payload carries — a renderer must never be the thing gating them.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { NotificationContent } from "./content";
|
|
42
|
+
import type { RenderContext, RenderContextOverrides } from "./context";
|
|
43
|
+
import type { NotificationKind } from "./kinds";
|
|
44
|
+
import type { NotificationPayloads } from "./payloads";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One kind's definition, parameterised by the kind so `compose` receives that
|
|
48
|
+
* kind's payload precisely rather than a union of every payload.
|
|
49
|
+
*
|
|
50
|
+
* `NotificationPayloads[K]` is also the structural lock that keeps the two
|
|
51
|
+
* vocabularies honest: a kind added to `NotificationKind` without facts in
|
|
52
|
+
* `./payloads` cannot be given a definition, and so cannot reach the registry.
|
|
53
|
+
*
|
|
54
|
+
* Members are arrow properties, not method shorthand: the vocabulary-guard reads
|
|
55
|
+
* method signatures as behaviour smuggled into the vocabulary.
|
|
56
|
+
*/
|
|
57
|
+
export interface NotificationDefinition<
|
|
58
|
+
K extends NotificationKind = NotificationKind,
|
|
59
|
+
> {
|
|
60
|
+
/** The kind this defines. MUST match its registry key. */
|
|
61
|
+
kind: K;
|
|
62
|
+
/**
|
|
63
|
+
* The kind's own ambient defaults, layered UNDER a caller's `RenderContext` —
|
|
64
|
+
* a kind may express a preference, a caller always overrules it.
|
|
65
|
+
*
|
|
66
|
+
* The seam exists here rather than on the payload because it is a standing
|
|
67
|
+
* fact about the kind, not about any one send: a notification a product sends
|
|
68
|
+
* on a third party's behalf is signed that way every time, and encoding that
|
|
69
|
+
* on each payload would make every caller responsible for remembering it.
|
|
70
|
+
*
|
|
71
|
+
* No kind sets this today. It is the one field kept ahead of a caller, and the
|
|
72
|
+
* first place to look if this layer starts accreting speculative surface.
|
|
73
|
+
*/
|
|
74
|
+
defaults?: RenderContextOverrides;
|
|
75
|
+
/**
|
|
76
|
+
* Say what this notification says. Pure: `(payload, context)` in, content out.
|
|
77
|
+
*/
|
|
78
|
+
compose: (
|
|
79
|
+
payload: NotificationPayloads[K],
|
|
80
|
+
context: RenderContext,
|
|
81
|
+
) => NotificationContent;
|
|
82
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications Domain Barrel
|
|
3
|
+
*
|
|
4
|
+
* The channel-agnostic notification render layer.
|
|
5
|
+
* Import from '@company-semantics/contracts/notifications'.
|
|
6
|
+
*
|
|
7
|
+
* @see ADR-CONTRACTS-086 for design rationale
|
|
8
|
+
* @see ADR-CONTRACTS-085 for the definition + registry layer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Content — the channel-agnostic semantic model
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
Body,
|
|
17
|
+
CallToAction,
|
|
18
|
+
ChatContinuation,
|
|
19
|
+
ChatTurn,
|
|
20
|
+
ChatUnit,
|
|
21
|
+
ChatUnitItem,
|
|
22
|
+
Divider,
|
|
23
|
+
Greeting,
|
|
24
|
+
HeroImage,
|
|
25
|
+
KeyValueRow,
|
|
26
|
+
KeyValueTable,
|
|
27
|
+
List,
|
|
28
|
+
MetadataElement,
|
|
29
|
+
NotificationContent,
|
|
30
|
+
NotificationElement,
|
|
31
|
+
NotificationElementType,
|
|
32
|
+
NotificationMetadata,
|
|
33
|
+
Notice,
|
|
34
|
+
Section,
|
|
35
|
+
Signature,
|
|
36
|
+
Warning,
|
|
37
|
+
} from "./content";
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Render Context
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export type { Brand, RenderContext, RenderContextOverrides } from "./context";
|
|
44
|
+
|
|
45
|
+
export { createRenderContext } from "./context";
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Renderer
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
export type { Renderer } from "./renderer";
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Channels — one renderer per channel
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
export type { RenderedEmail } from "./renderers/email";
|
|
58
|
+
|
|
59
|
+
export { emailRenderer } from "./renderers/email";
|
|
60
|
+
|
|
61
|
+
export type {
|
|
62
|
+
SlackActionsBlock,
|
|
63
|
+
SlackBlock,
|
|
64
|
+
SlackButtonElement,
|
|
65
|
+
SlackContextBlock,
|
|
66
|
+
SlackDividerBlock,
|
|
67
|
+
SlackImageBlock,
|
|
68
|
+
SlackMessage,
|
|
69
|
+
SlackMrkdwnText,
|
|
70
|
+
SlackPlainText,
|
|
71
|
+
SlackSectionBlock,
|
|
72
|
+
} from "./renderers/slack";
|
|
73
|
+
|
|
74
|
+
export { slackRenderer } from "./renderers/slack";
|
|
75
|
+
|
|
76
|
+
export { smsRenderer } from "./renderers/sms";
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Kinds — what notifications exist, and the facts each carries
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
export type { NotificationKind } from "./kinds";
|
|
83
|
+
|
|
84
|
+
export type { NotificationPayloads } from "./payloads";
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Definition + Registry — what each kind says
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
export type { NotificationDefinition } from "./definition";
|
|
91
|
+
|
|
92
|
+
export type { NotificationDefinitions } from "./registry";
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
getNotificationKindDefinition,
|
|
96
|
+
isValidNotificationKind,
|
|
97
|
+
NOTIFICATION_DEFINITIONS,
|
|
98
|
+
} from "./registry";
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Render — the entry point
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
export { renderEmail, renderToChannel } from "./render";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# notifications/kinds/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
One module per notification kind (ADR-CONTRACTS-085). Each exports a single
|
|
6
|
+
`NotificationDefinition<K>` whose `compose(payload, context)` says what that
|
|
7
|
+
notification says, as channel-agnostic `NotificationContent`.
|
|
8
|
+
|
|
9
|
+
These are the ported bodies of the old `src/email/render/*.ts` (deleted,
|
|
10
|
+
ADR-CONTRACTS-086). Each block call became an element node:
|
|
11
|
+
|
|
12
|
+
| old `email/render/blocks` | element |
|
|
13
|
+
| --------------------------------------------------- | ----------------------------------------------------------------- |
|
|
14
|
+
| `greeting(name?)` | `greeting` |
|
|
15
|
+
| `paragraph(text)` | `body` |
|
|
16
|
+
| `keyValue(label, value)` × n (consecutive) | ONE `keyValueTable` with `n` rows |
|
|
17
|
+
| `security()` | `warning` |
|
|
18
|
+
| `footer(first, second)` | `notice` with two `lines` |
|
|
19
|
+
| `signature()` | `signature` |
|
|
20
|
+
| `chatUnit(chatUser/chatAssistant/chatDots/chatCta)` | `chatUnit` with `message` / `continuation` / `callToAction` items |
|
|
21
|
+
|
|
22
|
+
Two things are dropped in the crossing, both because they are a channel's answer
|
|
23
|
+
rather than the notification's: `Spacing` (`"normal"` / `"tight"` / `"none"`),
|
|
24
|
+
and the `Block`'s markup itself.
|
|
25
|
+
|
|
26
|
+
**Punctuation between a label and its value is dropped with them, and the email
|
|
27
|
+
renderer MUST put it back.** The old `keyValue("Status", "Valid")` rendered
|
|
28
|
+
`Status: Valid`, and `auth-otp`'s `paragraph("Request details:")` a literal
|
|
29
|
+
trailing colon — but `KeyValueRow` owns no separator ("the renderer owns the
|
|
30
|
+
separator and alignment") and `MetadataElement.label` is `"Request details"`, no
|
|
31
|
+
colon, exactly as `../content.ts` documents it. The colon is presentation and
|
|
32
|
+
appears in NO element. A renderer that forgets it drops `:` from every key/value
|
|
33
|
+
row and from the request-details heading.
|
|
34
|
+
|
|
35
|
+
The registry subject moves the other way — INTO `compose`, as `metadata.title`.
|
|
36
|
+
The old `renderEmail`'s `resolveSubject` / `subjectData` pair has no equivalent
|
|
37
|
+
here: a `{orgName}` placeholder resolved against an untyped bag becomes a
|
|
38
|
+
template literal the compiler checks.
|
|
39
|
+
|
|
40
|
+
## Invariants
|
|
41
|
+
|
|
42
|
+
- `compose` is PURE and total: `(payload, context)` in, content out. No clock
|
|
43
|
+
(`../context` owns the only one), no environment, no I/O.
|
|
44
|
+
- `compose` emits CONTENT — never markup, styling, spacing or a channel name.
|
|
45
|
+
- The brand comes from `context.brand.name`. A definition MUST NOT import
|
|
46
|
+
`COMPANY_NAME`; that ambient read is what this layer exists to remove.
|
|
47
|
+
- Element ORDER is content. A port must not regroup elements to look tidier —
|
|
48
|
+
see `security-alert.ts`, which emits two `keyValueTable`s precisely because
|
|
49
|
+
merging them would move what the recipient reads.
|
|
50
|
+
- Definitions take `formatExpiry`, `titleCase`, `NOTICE` and `ACCESS_PHRASE` from
|
|
51
|
+
`../text` — never from a renderer. They are content (phrasing and display
|
|
52
|
+
format), not markup, so they sit beside the kinds rather than inside the
|
|
53
|
+
channel that used to own them. The output-parity golden asserts these strings
|
|
54
|
+
byte-for-byte; a second copy of one is the exact failure this migration exists
|
|
55
|
+
to prevent.
|
|
56
|
+
- A file per kind, named after its old `email/render` counterpart. Kinds do not
|
|
57
|
+
share a module — the point is that a kind is self-contained.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth OTP notification (login code).
|
|
3
|
+
*
|
|
4
|
+
* INVARIANT: the OTP value is a runtime payload field — never a literal in this
|
|
5
|
+
* package.
|
|
6
|
+
*
|
|
7
|
+
* PRIVACY: request IP / device are PII under GDPR. The old `renderAuthOtp` took
|
|
8
|
+
* an `includeRequestMetadata` option and the backend passed its env flag. Here the
|
|
9
|
+
* gate is simply whether the payload carries them: a caller that must not show
|
|
10
|
+
* request details omits the fields, and `compose` has no third parameter to
|
|
11
|
+
* disagree with. The decision stays upstream where the policy lives, and the PII
|
|
12
|
+
* stops being handed to a layer that was told to ignore it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { KeyValueRow, NotificationElement } from "../content";
|
|
16
|
+
import type { NotificationDefinition } from "../definition";
|
|
17
|
+
|
|
18
|
+
/** Longest user agent shown before ellipsis, and the slice that leaves room for it. */
|
|
19
|
+
const USER_AGENT_MAX = 80;
|
|
20
|
+
const USER_AGENT_TRUNCATED = 77;
|
|
21
|
+
|
|
22
|
+
export const authOtpDefinition: NotificationDefinition<"auth.otp"> = {
|
|
23
|
+
kind: "auth.otp",
|
|
24
|
+
compose: (payload, context) => {
|
|
25
|
+
const { otp, expiresInMinutes, requestIp, userAgent } = payload;
|
|
26
|
+
|
|
27
|
+
const requestRows: KeyValueRow[] = [
|
|
28
|
+
...(requestIp ? [{ label: "IP address", value: requestIp }] : []),
|
|
29
|
+
...(userAgent
|
|
30
|
+
? [
|
|
31
|
+
{
|
|
32
|
+
label: "Device",
|
|
33
|
+
value:
|
|
34
|
+
userAgent.length > USER_AGENT_MAX
|
|
35
|
+
? `${userAgent.slice(0, USER_AGENT_TRUNCATED)}...`
|
|
36
|
+
: userAgent,
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
: []),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const elements: NotificationElement[] = [
|
|
43
|
+
{ type: "greeting" },
|
|
44
|
+
{ type: "body", text: "A login code was requested." },
|
|
45
|
+
{
|
|
46
|
+
type: "chatUnit",
|
|
47
|
+
items: [
|
|
48
|
+
{
|
|
49
|
+
type: "message",
|
|
50
|
+
role: "assistant",
|
|
51
|
+
text: "Copy + paste this code in the login form.",
|
|
52
|
+
},
|
|
53
|
+
// No href: the code IS the payload, and a renderer must not invent a
|
|
54
|
+
// destination for it.
|
|
55
|
+
{ type: "callToAction", label: otp },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "keyValueTable",
|
|
60
|
+
rows: [
|
|
61
|
+
{ label: "Status", value: "Valid" },
|
|
62
|
+
{
|
|
63
|
+
label: "Expires in",
|
|
64
|
+
value: `${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
...(requestRows.length
|
|
69
|
+
? [
|
|
70
|
+
{
|
|
71
|
+
// "Request details", not "Request details:" — the email layer's
|
|
72
|
+
// literal colon is a separator, and separators are the renderer's
|
|
73
|
+
// (see `../content.ts`, which uses this exact label as its
|
|
74
|
+
// example). The email renderer must put the colon back.
|
|
75
|
+
type: "metadata" as const,
|
|
76
|
+
label: "Request details",
|
|
77
|
+
rows: requestRows,
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
: []),
|
|
81
|
+
{
|
|
82
|
+
type: "notice",
|
|
83
|
+
lines: [
|
|
84
|
+
`This login code was sent via ${context.brand.name}.`,
|
|
85
|
+
"If this wasn't you, no action is required.",
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{ type: "signature" },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
metadata: {
|
|
93
|
+
kind: "auth.otp",
|
|
94
|
+
title: "Your login code for Company Semantics",
|
|
95
|
+
},
|
|
96
|
+
sections: [{ elements }],
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|