@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.
Files changed (78) hide show
  1. package/package.json +4 -4
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +2 -2
  4. package/src/index.ts +17 -11
  5. package/src/notifications/README.md +140 -0
  6. package/src/notifications/__tests__/README.md +54 -0
  7. package/src/notifications/__tests__/__snapshots__/README.md +32 -0
  8. package/src/notifications/__tests__/content.test.ts +186 -0
  9. package/src/notifications/__tests__/context.test.ts +72 -0
  10. package/src/notifications/__tests__/definition.test.ts +222 -0
  11. package/src/notifications/__tests__/kinds.test.ts +81 -0
  12. package/src/notifications/__tests__/output-parity.golden.ts +363 -0
  13. package/src/notifications/__tests__/output-parity.test.ts +122 -0
  14. package/src/notifications/__tests__/registry.test.ts +184 -0
  15. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
  16. package/src/notifications/__tests__/renderer.test.ts +181 -0
  17. package/src/notifications/content.ts +249 -0
  18. package/src/notifications/context.ts +70 -0
  19. package/src/notifications/definition.ts +82 -0
  20. package/src/notifications/index.ts +104 -0
  21. package/src/notifications/kinds/README.md +57 -0
  22. package/src/notifications/kinds/auth-otp.ts +99 -0
  23. package/src/notifications/kinds/chat-shared.ts +56 -0
  24. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  25. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  26. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  27. package/src/notifications/kinds/index.ts +19 -0
  28. package/src/notifications/kinds/org-invite.ts +62 -0
  29. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  30. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  31. package/src/notifications/kinds/security-alert.ts +78 -0
  32. package/src/notifications/kinds/share-granted.ts +74 -0
  33. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  34. package/src/notifications/kinds.ts +68 -0
  35. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  36. package/src/notifications/registry.ts +107 -0
  37. package/src/notifications/render.ts +106 -0
  38. package/src/notifications/renderer.ts +50 -0
  39. package/src/notifications/renderers/README.md +50 -0
  40. package/src/notifications/renderers/email/README.md +74 -0
  41. package/src/notifications/renderers/email/__tests__/README.md +29 -0
  42. package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
  43. package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
  44. package/src/notifications/renderers/email/constants.ts +47 -0
  45. package/src/notifications/renderers/email/cta.ts +63 -0
  46. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  47. package/src/notifications/renderers/email/index.ts +73 -0
  48. package/src/notifications/renderers/email/render.ts +225 -0
  49. package/src/notifications/renderers/email/shells.ts +60 -0
  50. package/src/notifications/renderers/slack/README.md +73 -0
  51. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  52. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  53. package/src/notifications/renderers/slack/index.ts +261 -0
  54. package/src/notifications/renderers/sms/README.md +69 -0
  55. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  56. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  57. package/src/notifications/renderers/sms/index.ts +131 -0
  58. package/src/notifications/text.ts +52 -0
  59. package/src/email/README.md +0 -51
  60. package/src/email/__tests__/registry.test.ts +0 -161
  61. package/src/email/index.ts +0 -36
  62. package/src/email/registry.ts +0 -155
  63. package/src/email/render/auth-otp.ts +0 -71
  64. package/src/email/render/blocks.ts +0 -281
  65. package/src/email/render/chat-shared.ts +0 -35
  66. package/src/email/render/company-md-access-approved.ts +0 -42
  67. package/src/email/render/company-md-access-denied.ts +0 -43
  68. package/src/email/render/company-md-access-requested.ts +0 -46
  69. package/src/email/render/constants.ts +0 -18
  70. package/src/email/render/index.ts +0 -58
  71. package/src/email/render/org-invite.ts +0 -40
  72. package/src/email/render/ownership-transfer-completed.ts +0 -41
  73. package/src/email/render/ownership-transfer.ts +0 -42
  74. package/src/email/render/render-email.ts +0 -194
  75. package/src/email/render/security-alert.ts +0 -61
  76. package/src/email/render/share-granted.ts +0 -52
  77. package/src/email/render/unit-owner-granted.ts +0 -60
  78. /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
+ };