@company-semantics/contracts 34.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/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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit-owner-granted notification (added to a team's unit ownership).
|
|
3
|
+
*
|
|
4
|
+
* THREE ROLE STRINGS, DELIBERATELY DIFFERENT — do not unify them:
|
|
5
|
+
* - `roleWord` ("owner" / "delegate owner") — the title's short form. Derived
|
|
6
|
+
* here because the payload does not carry it; this is `subjectData`'s only job
|
|
7
|
+
* in `renderEmail` today, and it folds into `compose` with it.
|
|
8
|
+
* - `roleNoun` ("unit owner" / "delegate owner") — the body's prose form. Note it
|
|
9
|
+
* disagrees with `roleWord` for a formal owner, on purpose: the title is terse,
|
|
10
|
+
* the body is explanatory.
|
|
11
|
+
* - `titleCase(roleLabel)` ("Unit Owner" / "Delegate") — the Role row's display
|
|
12
|
+
* form, which is the payload's own label.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChatUnitItem, KeyValueRow } from "../content";
|
|
16
|
+
import type { NotificationDefinition } from "../definition";
|
|
17
|
+
|
|
18
|
+
import { formatExpiry, NOTICE, titleCase } from "../text";
|
|
19
|
+
|
|
20
|
+
export const unitOwnerGrantedDefinition: NotificationDefinition<"org.unit_owner_granted"> =
|
|
21
|
+
{
|
|
22
|
+
kind: "org.unit_owner_granted",
|
|
23
|
+
compose: (payload, context) => {
|
|
24
|
+
const {
|
|
25
|
+
granterName,
|
|
26
|
+
recipientName,
|
|
27
|
+
orgName,
|
|
28
|
+
unitName,
|
|
29
|
+
roleLabel,
|
|
30
|
+
ctaUrl,
|
|
31
|
+
message,
|
|
32
|
+
expiresAt,
|
|
33
|
+
} = payload;
|
|
34
|
+
|
|
35
|
+
const roleWord = roleLabel === "Delegate" ? "delegate owner" : "owner";
|
|
36
|
+
const roleNoun =
|
|
37
|
+
roleLabel === "Delegate" ? "delegate owner" : "unit owner";
|
|
38
|
+
|
|
39
|
+
const items: ChatUnitItem[] = [
|
|
40
|
+
...(message
|
|
41
|
+
? [
|
|
42
|
+
{
|
|
43
|
+
type: "message" as const,
|
|
44
|
+
role: "user" as const,
|
|
45
|
+
text: message,
|
|
46
|
+
from: granterName,
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
: []),
|
|
50
|
+
{
|
|
51
|
+
type: "message",
|
|
52
|
+
role: "assistant",
|
|
53
|
+
text: "Open the team to get started.",
|
|
54
|
+
},
|
|
55
|
+
{ type: "callToAction", label: "MANAGE TEAM", href: ctaUrl },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const rows: KeyValueRow[] = [
|
|
59
|
+
{ label: "From", value: granterName },
|
|
60
|
+
{ label: "Team", value: unitName },
|
|
61
|
+
{ label: "Role", value: titleCase(roleLabel) },
|
|
62
|
+
...(expiresAt != null
|
|
63
|
+
? [{ label: "Expires", value: formatExpiry(expiresAt) }]
|
|
64
|
+
: []),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
metadata: {
|
|
69
|
+
kind: "org.unit_owner_granted",
|
|
70
|
+
title: `You've been added as ${roleWord} to ${unitName} in ${orgName}`,
|
|
71
|
+
},
|
|
72
|
+
sections: [
|
|
73
|
+
{
|
|
74
|
+
elements: [
|
|
75
|
+
{ type: "greeting", ...(recipientName ? { recipientName } : {}) },
|
|
76
|
+
{
|
|
77
|
+
type: "body",
|
|
78
|
+
text: `You are now a ${roleNoun} of ${unitName}. ${roleNoun[0].toUpperCase()}${roleNoun.slice(1)}s hold authority over the team: managing team membership, doc access, strategy, and execution against goals.`,
|
|
79
|
+
},
|
|
80
|
+
{ type: "chatUnit", items },
|
|
81
|
+
{ type: "keyValueTable", rows },
|
|
82
|
+
{
|
|
83
|
+
type: "notice",
|
|
84
|
+
lines: [
|
|
85
|
+
`This notification was sent via ${context.brand.name}.`,
|
|
86
|
+
NOTICE,
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{ type: "signature" },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What notifications exist (ADR-CONTRACTS-085).
|
|
3
|
+
*
|
|
4
|
+
* `NotificationKind` is the old `EmailKind` renamed and narrowed to the kinds
|
|
5
|
+
* that can actually be composed. `EmailKind` derived from this union while both
|
|
6
|
+
* layers existed; it was deleted with `src/email/` (ADR-CONTRACTS-086), so this
|
|
7
|
+
* is now the only union of its kind in the package.
|
|
8
|
+
*
|
|
9
|
+
* NOTE ON MODULE RESOLUTION: this file is `kinds.ts` and its neighbour is the
|
|
10
|
+
* `kinds/` directory. Under `moduleResolution: "bundler"` the specifier
|
|
11
|
+
* `./kinds` resolves to THIS FILE, never to `kinds/index.ts` — import the
|
|
12
|
+
* definition barrel explicitly as `./kinds/index`. The split is deliberate: this
|
|
13
|
+
* file is the vocabulary (which kinds exist), the directory is one definition
|
|
14
|
+
* per kind (what each one says).
|
|
15
|
+
*
|
|
16
|
+
* INVARIANTS:
|
|
17
|
+
* - Every member has a payload in `./payloads` and a definition in `./kinds/`.
|
|
18
|
+
* Both are enforced structurally rather than by comment — see `./definition`
|
|
19
|
+
* and `./registry`.
|
|
20
|
+
* - `{domain}.{type}` dot notation, matching the ids the email layer shipped on
|
|
21
|
+
* the wire. These strings are persisted by consumers; renaming one is a
|
|
22
|
+
* breaking change, not a tidy-up.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// NotificationKind Union
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* NotificationKind identifies the type of transactional notification.
|
|
31
|
+
*
|
|
32
|
+
* Naming convention: `{domain}.{type}`
|
|
33
|
+
* - domain: auth, org, security, chat, share, companyMd
|
|
34
|
+
* - type: specific notification variant
|
|
35
|
+
*
|
|
36
|
+
* This union is exactly the kinds with a `compose` implementation — the 11 the
|
|
37
|
+
* email layer could render, and deliberately NOT the 12 its `EmailKind` had.
|
|
38
|
+
* `auth.magic_link` was registered with a subject but never had a template, so
|
|
39
|
+
* the old `renderEmail("auth.magic_link", …)` type-checked and then threw at
|
|
40
|
+
* runtime. Excluding it closes that hole by construction: a kind IS its
|
|
41
|
+
* definition, `NOTIFICATION_DEFINITIONS` is total, and
|
|
42
|
+
* `getNotificationKindDefinition` cannot fail. A kind earns membership by having
|
|
43
|
+
* something to say, not by having a subject line reserved for it.
|
|
44
|
+
*
|
|
45
|
+
* That kind and its reserved subject left the package with `src/email/` and have
|
|
46
|
+
* no replacement here. Magic-link login is not a thing this layer can say; if it
|
|
47
|
+
* returns, it returns as a definition like any other.
|
|
48
|
+
*
|
|
49
|
+
* New kinds MUST be added to:
|
|
50
|
+
* 1. This union
|
|
51
|
+
* 2. `NotificationPayloads` in `./payloads`
|
|
52
|
+
* 3. A definition module under `./kinds/`, exported from `./kinds/index`
|
|
53
|
+
* 4. `NOTIFICATION_DEFINITIONS` in `./registry`
|
|
54
|
+
*
|
|
55
|
+
* Steps 2 and 4 are compiler-enforced; the union will not type-check without them.
|
|
56
|
+
*/
|
|
57
|
+
export type NotificationKind =
|
|
58
|
+
| "auth.otp"
|
|
59
|
+
| "org.invite"
|
|
60
|
+
| "org.unit_owner_granted"
|
|
61
|
+
| "org.ownership_transfer"
|
|
62
|
+
| "org.ownership_transfer_completed"
|
|
63
|
+
| "security.alert"
|
|
64
|
+
| "chat.shared"
|
|
65
|
+
| "share.granted"
|
|
66
|
+
| "companyMd.access_requested"
|
|
67
|
+
| "companyMd.access_request_approved"
|
|
68
|
+
| "companyMd.access_request_denied";
|
|
@@ -1,61 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The business facts each notification kind carries (ADR-CONTRACTS-085).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* This is the old `EmailPayloads` renamed, not redesigned: the same per-kind
|
|
5
|
+
* fields, in the same shapes, moved to the layer that actually owns them. A
|
|
6
|
+
* payload was never an email concept — "who invited you, to which workspace,
|
|
7
|
+
* until when" is true of the notification regardless of whether it arrives as an
|
|
8
|
+
* email, a chat message or a push. `EmailPayloads` aliased this type while both
|
|
9
|
+
* layers existed, and was deleted with `src/email/` (ADR-CONTRACTS-086).
|
|
6
10
|
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// =============================================================================
|
|
11
|
-
// EmailKind Union
|
|
12
|
-
// =============================================================================
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* EmailKind identifies the type of transactional email.
|
|
11
|
+
* A payload carries FACTS, never copy. "Jun 13, 2026" and "You've been added as
|
|
12
|
+
* owner" are `compose`'s output, not a caller's input — that split is what lets
|
|
13
|
+
* one payload render to every channel.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* 2. EMAIL_KINDS registry in registry.ts
|
|
24
|
-
* 3. EmailPayloads interface (if kind has payload)
|
|
15
|
+
* INVARIANTS:
|
|
16
|
+
* - Types only. No markup, no copy, no formatted values — an `expiresAt` is an
|
|
17
|
+
* ISO timestamp here and becomes a date string in `compose`.
|
|
18
|
+
* - Keys are exactly `NotificationKind`. `../definition` enforces this
|
|
19
|
+
* structurally: `NotificationPayloads[K]` fails to compile for a kind with no
|
|
20
|
+
* entry here, so a kind cannot exist without its facts.
|
|
25
21
|
*/
|
|
26
|
-
export type EmailKind =
|
|
27
|
-
| "auth.otp"
|
|
28
|
-
| "auth.magic_link" // future
|
|
29
|
-
| "org.invite" // future
|
|
30
|
-
| "org.unit_owner_granted"
|
|
31
|
-
| "org.ownership_transfer"
|
|
32
|
-
| "org.ownership_transfer_completed"
|
|
33
|
-
| "security.alert" // future
|
|
34
|
-
| "chat.shared"
|
|
35
|
-
| "share.granted"
|
|
36
|
-
| "companyMd.access_requested"
|
|
37
|
-
| "companyMd.access_request_approved"
|
|
38
|
-
| "companyMd.access_request_denied";
|
|
39
22
|
|
|
40
23
|
// =============================================================================
|
|
41
|
-
//
|
|
24
|
+
// Notification Payloads
|
|
42
25
|
// =============================================================================
|
|
43
26
|
|
|
44
27
|
/**
|
|
45
|
-
* Type-safe payload mapping for each
|
|
28
|
+
* Type-safe payload mapping for each notification kind.
|
|
46
29
|
*
|
|
47
|
-
* Each key is
|
|
48
|
-
* Kinds without entries here have empty payloads.
|
|
30
|
+
* Each key is a `NotificationKind`, and the value is the required payload shape.
|
|
49
31
|
*/
|
|
50
|
-
export interface
|
|
32
|
+
export interface NotificationPayloads {
|
|
51
33
|
"auth.otp": {
|
|
52
34
|
/** The 6-digit OTP code */
|
|
53
35
|
otp: string;
|
|
54
36
|
/** How long until the code expires */
|
|
55
37
|
expiresInMinutes: number;
|
|
56
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* IP address of the request (for security context). PII under GDPR: the
|
|
40
|
+
* caller decides whether to supply it at all, and `compose` shows request
|
|
41
|
+
* details only when it is present. See ADR-CONTRACTS-085.
|
|
42
|
+
*/
|
|
57
43
|
requestIp?: string;
|
|
58
|
-
/** User agent of the request (for security context) */
|
|
44
|
+
/** User agent of the request (for security context). PII-gated upstream, as `requestIp`. */
|
|
59
45
|
userAgent?: string;
|
|
60
46
|
};
|
|
61
47
|
"org.invite": {
|
|
@@ -71,7 +57,7 @@ export interface EmailPayloads {
|
|
|
71
57
|
granterName: string;
|
|
72
58
|
/** Display name of the recipient (optional; falls back to a neutral greeting) */
|
|
73
59
|
recipientName?: string;
|
|
74
|
-
/** Name of the organization / workspace (rendered in the
|
|
60
|
+
/** Name of the organization / workspace (rendered in the title) */
|
|
75
61
|
orgName: string;
|
|
76
62
|
/** Name of the org unit / team the access applies to */
|
|
77
63
|
unitName: string;
|
|
@@ -79,13 +65,13 @@ export interface EmailPayloads {
|
|
|
79
65
|
roleLabel: "Unit owner" | "Delegate";
|
|
80
66
|
/** Full URL to view the org unit in the app */
|
|
81
67
|
ctaUrl: string;
|
|
82
|
-
/** Optional message from the granter, shown in the
|
|
68
|
+
/** Optional message from the granter, shown in the notification and recorded with the grant */
|
|
83
69
|
message?: string;
|
|
84
70
|
/** ISO timestamp when the grant expires. Absent = permanent (formal unit owners; delegations with no expiry). Rendered as the last key/value row ("Expires: Jun 13, 2026") when present. */
|
|
85
71
|
expiresAt?: string;
|
|
86
72
|
};
|
|
87
73
|
"org.ownership_transfer": {
|
|
88
|
-
/** Name of the organization being transferred (
|
|
74
|
+
/** Name of the organization being transferred (title is static; org name renders in the body) */
|
|
89
75
|
orgName: string;
|
|
90
76
|
/** Full URL to accept the transfer (token-bearing) */
|
|
91
77
|
acceptUrl: string;
|
|
@@ -119,7 +105,7 @@ export interface EmailPayloads {
|
|
|
119
105
|
shareUrl: string;
|
|
120
106
|
/** Share visibility mode */
|
|
121
107
|
visibility: "private" | "public";
|
|
122
|
-
/** First ~200 chars of chat content for
|
|
108
|
+
/** First ~200 chars of chat content for preview. Generated server-side from snapshot. */
|
|
123
109
|
previewText?: string;
|
|
124
110
|
};
|
|
125
111
|
"share.granted": {
|
|
@@ -135,7 +121,7 @@ export interface EmailPayloads {
|
|
|
135
121
|
accessLevel: "editor" | "commenter" | "viewer";
|
|
136
122
|
/** Full URL to view the shared entity in the app */
|
|
137
123
|
ctaUrl: string;
|
|
138
|
-
/** Optional message from the granter, included in the
|
|
124
|
+
/** Optional message from the granter, included in the notification only (not persisted) */
|
|
139
125
|
message?: string;
|
|
140
126
|
};
|
|
141
127
|
/** Sent to the doc owner(s) when an actor requests access (ADR-BE-338). */
|
|
@@ -144,7 +130,7 @@ export interface EmailPayloads {
|
|
|
144
130
|
requesterName: string;
|
|
145
131
|
/** Title of the company.md node access is requested for */
|
|
146
132
|
docTitle: string;
|
|
147
|
-
/** Optional message from the requester (
|
|
133
|
+
/** Optional message from the requester (notification only) */
|
|
148
134
|
message?: string;
|
|
149
135
|
/** Deep-link that opens the doc's ShareDialog scrolled to the pending request */
|
|
150
136
|
reviewUrl: string;
|
|
@@ -166,28 +152,7 @@ export interface EmailPayloads {
|
|
|
166
152
|
approverName: string;
|
|
167
153
|
/** Title of the company.md node */
|
|
168
154
|
docTitle: string;
|
|
169
|
-
/** Optional reason from the owner (
|
|
155
|
+
/** Optional reason from the owner (notification only) */
|
|
170
156
|
reason?: string;
|
|
171
157
|
};
|
|
172
158
|
}
|
|
173
|
-
|
|
174
|
-
// =============================================================================
|
|
175
|
-
// Send Email Input
|
|
176
|
-
// =============================================================================
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Type-safe input for sending emails.
|
|
180
|
-
*
|
|
181
|
-
* The payload type is inferred from the kind.
|
|
182
|
-
* If the kind is in EmailPayloads, that payload is required.
|
|
183
|
-
*/
|
|
184
|
-
export interface SendEmailInput<K extends EmailKind> {
|
|
185
|
-
/** The type of email to send */
|
|
186
|
-
kind: K;
|
|
187
|
-
/** Recipient email address (will be normalized) */
|
|
188
|
-
to: string;
|
|
189
|
-
/** Type-safe payload for this email kind */
|
|
190
|
-
payload: K extends keyof EmailPayloads ? EmailPayloads[K] : never;
|
|
191
|
-
/** Idempotency key to prevent duplicate sends */
|
|
192
|
-
idempotencyKey: string;
|
|
193
|
-
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification definition registry (ADR-CONTRACTS-085).
|
|
3
|
+
*
|
|
4
|
+
* `NOTIFICATION_DEFINITIONS` is the single source of truth for what each kind
|
|
5
|
+
* says, and replaced `EMAIL_KINDS` as the place a caller starts from. What it
|
|
6
|
+
* does not hold is as important as what it does: no subject (that is
|
|
7
|
+
* `compose`'s `metadata.title`), and no `plainTextRequired` / `htmlSupported`
|
|
8
|
+
* (that is `Renderer.supports`). What remains is the kind and how to say it.
|
|
9
|
+
*
|
|
10
|
+
* INVARIANTS:
|
|
11
|
+
* - Exhaustive over `NotificationKind`, by construction — `NotificationDefinitions`
|
|
12
|
+
* is a mapped type, so a kind with no entry is a compile error rather than an
|
|
13
|
+
* `undefined` at runtime.
|
|
14
|
+
* - Registry keys MUST match `definition.kind`. Enforced by the mapped type: the
|
|
15
|
+
* value at key `K` is a `NotificationDefinition<K>`, whose `kind` is `K`.
|
|
16
|
+
* - Total: `getNotificationKindDefinition` cannot fail for a `NotificationKind`.
|
|
17
|
+
* This is the improvement on `EMAIL_KINDS`, where `auth.magic_link` type-checked
|
|
18
|
+
* and then threw — see `./kinds` for why the union excludes it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { NotificationDefinition } from "./definition";
|
|
22
|
+
import type { NotificationKind } from "./kinds";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
accessApprovedDefinition,
|
|
26
|
+
accessDeniedDefinition,
|
|
27
|
+
accessRequestedDefinition,
|
|
28
|
+
authOtpDefinition,
|
|
29
|
+
chatSharedDefinition,
|
|
30
|
+
orgInviteDefinition,
|
|
31
|
+
ownershipTransferCompletedDefinition,
|
|
32
|
+
ownershipTransferDefinition,
|
|
33
|
+
securityAlertDefinition,
|
|
34
|
+
shareGrantedDefinition,
|
|
35
|
+
unitOwnerGrantedDefinition,
|
|
36
|
+
} from "./kinds/index";
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Registry
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The registry's shape: `Record<NotificationKind, NotificationDefinition>` made
|
|
44
|
+
* per-kind precise.
|
|
45
|
+
*
|
|
46
|
+
* A plain `Record` would widen every `compose` to accept a union of all eleven
|
|
47
|
+
* payloads, and — since `compose` is an arrow property, checked contravariantly
|
|
48
|
+
* — no real definition would be assignable to it. The mapped type keeps each
|
|
49
|
+
* kind bound to its own payload, which is what a caller wants anyway.
|
|
50
|
+
*/
|
|
51
|
+
export type NotificationDefinitions = {
|
|
52
|
+
[K in NotificationKind]: NotificationDefinition<K>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every notification kind and its definition.
|
|
57
|
+
*
|
|
58
|
+
* To add a kind:
|
|
59
|
+
* 1. Add it to `NotificationKind` in `./kinds`
|
|
60
|
+
* 2. Add its facts to `NotificationPayloads` in `./payloads`
|
|
61
|
+
* 3. Write `./kinds/{kind}.ts` and export it from `./kinds/index`
|
|
62
|
+
* 4. Add it here
|
|
63
|
+
*
|
|
64
|
+
* Steps 2–4 are compiler-enforced; step 1 alone will not type-check.
|
|
65
|
+
*/
|
|
66
|
+
export const NOTIFICATION_DEFINITIONS: NotificationDefinitions = {
|
|
67
|
+
"auth.otp": authOtpDefinition,
|
|
68
|
+
"org.invite": orgInviteDefinition,
|
|
69
|
+
"org.unit_owner_granted": unitOwnerGrantedDefinition,
|
|
70
|
+
"org.ownership_transfer": ownershipTransferDefinition,
|
|
71
|
+
"org.ownership_transfer_completed": ownershipTransferCompletedDefinition,
|
|
72
|
+
"security.alert": securityAlertDefinition,
|
|
73
|
+
"chat.shared": chatSharedDefinition,
|
|
74
|
+
"share.granted": shareGrantedDefinition,
|
|
75
|
+
"companyMd.access_requested": accessRequestedDefinition,
|
|
76
|
+
"companyMd.access_request_approved": accessApprovedDefinition,
|
|
77
|
+
"companyMd.access_request_denied": accessDeniedDefinition,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// Registry Helpers
|
|
82
|
+
// =============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Type-safe registry lookup. Generic in `K` so a caller with a literal kind gets
|
|
86
|
+
* that kind's definition — and so its `compose` takes that kind's payload —
|
|
87
|
+
* rather than a union it would have to narrow again.
|
|
88
|
+
*/
|
|
89
|
+
export function getNotificationKindDefinition<K extends NotificationKind>(
|
|
90
|
+
kind: K,
|
|
91
|
+
): NotificationDefinitions[K] {
|
|
92
|
+
return NOTIFICATION_DEFINITIONS[kind];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if a string is a valid `NotificationKind`.
|
|
97
|
+
* Use at API boundaries to reject unknown kinds.
|
|
98
|
+
*
|
|
99
|
+
* `Object.hasOwn`, not `in`: this is fed untrusted strings by definition, and
|
|
100
|
+
* `in` walks the prototype chain — the old `isValidEmailKind("toString")`
|
|
101
|
+
* returned true. The registry's OWN keys are the vocabulary; `Object`'s are not.
|
|
102
|
+
*/
|
|
103
|
+
export function isValidNotificationKind(
|
|
104
|
+
kind: string,
|
|
105
|
+
): kind is NotificationKind {
|
|
106
|
+
return Object.hasOwn(NOTIFICATION_DEFINITIONS, kind);
|
|
107
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entry point (ADR-CONTRACTS-086).
|
|
3
|
+
*
|
|
4
|
+
* `renderToChannel` is the whole pipeline in one call: look the kind up in
|
|
5
|
+
* `NOTIFICATION_DEFINITIONS`, `compose` its payload into channel-agnostic
|
|
6
|
+
* `NotificationContent`, hand that to a `Renderer`. Every piece already existed
|
|
7
|
+
* and is already proven byte-for-byte against the output-parity golden — this
|
|
8
|
+
* module is the wiring, not a rendering change.
|
|
9
|
+
*
|
|
10
|
+
* What it deliberately does NOT do is dispatch. The old `renderEmail` was a
|
|
11
|
+
* twelve-arm `switch` that threw on the one arm it did not have; here the
|
|
12
|
+
* registry is a mapped type over `NotificationKind`, so the lookup cannot miss
|
|
13
|
+
* and there is no `default:` to fall into. Adding a channel adds a `Renderer`
|
|
14
|
+
* and touches nothing here; adding a kind adds a definition and touches nothing
|
|
15
|
+
* here. That is the point of the seam.
|
|
16
|
+
*
|
|
17
|
+
* `renderEmail` is a convenience over it, kept because email is the channel
|
|
18
|
+
* every caller has today and `renderToChannel(kind, payload, emailRenderer,
|
|
19
|
+
* createRenderContext())` is a lot of ceremony for "send this email". It REPLACES
|
|
20
|
+
* the old `email/render/render-email`, which was deleted along with the `./email`
|
|
21
|
+
* subpath (ADR-CONTRACTS-086) — same name, different third argument: a
|
|
22
|
+
* render-context concern rather than `RenderEmailOptions`.
|
|
23
|
+
* `includeRequestMetadata` has no equivalent and needs none — `auth.otp`'s
|
|
24
|
+
* `compose` gates request details on whether the payload carries them, so the
|
|
25
|
+
* decision stays with the caller that holds the policy instead of being handed
|
|
26
|
+
* PII and told to ignore it.
|
|
27
|
+
*
|
|
28
|
+
* INVARIANTS:
|
|
29
|
+
* - `renderToChannel` is PURE when given a `RenderContext`. It reads the clock
|
|
30
|
+
* only to build one when the caller supplies none, and only via
|
|
31
|
+
* `createRenderContext` — the single sanctioned clock read in this domain.
|
|
32
|
+
* Pass a context to get reproducible bytes.
|
|
33
|
+
* - Output MUST equal what the old email layer emitted, byte for byte, for every
|
|
34
|
+
* fixture in `__tests__/output-parity.golden.ts`. That is what
|
|
35
|
+
* `__tests__/output-parity.test.ts` asserts, and it is the only reason this
|
|
36
|
+
* rewiring is safe. The golden is now the sole record of those bytes.
|
|
37
|
+
* - A kind's `defaults` layer UNDER the caller's wishes, never over them. A full
|
|
38
|
+
* `RenderContext` has no gaps left to fill, so it wins outright; partial
|
|
39
|
+
* overrides are filled from `defaults` first and the package default last.
|
|
40
|
+
* - Depends only on this domain. The cycle back into `../email` that this layer
|
|
41
|
+
* existed to unwind is gone with the directory; nothing may reintroduce one.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { RenderContext, RenderContextOverrides } from "./context";
|
|
45
|
+
import { createRenderContext } from "./context";
|
|
46
|
+
import type { NotificationKind } from "./kinds";
|
|
47
|
+
import type { NotificationPayloads } from "./payloads";
|
|
48
|
+
import { NOTIFICATION_DEFINITIONS } from "./registry";
|
|
49
|
+
import type { Renderer } from "./renderer";
|
|
50
|
+
import type { RenderedEmail } from "./renderers/email";
|
|
51
|
+
import { emailRenderer } from "./renderers/email";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Layer a kind's `defaults` under a caller's overrides, field by field.
|
|
55
|
+
*
|
|
56
|
+
* Shallow-merging `brand` rather than replacing it is the difference between
|
|
57
|
+
* "the caller pinned the year" and "the caller pinned the year and silently lost
|
|
58
|
+
* the kind's signer".
|
|
59
|
+
*/
|
|
60
|
+
function layerOverrides(
|
|
61
|
+
defaults: RenderContextOverrides | undefined,
|
|
62
|
+
overrides: RenderContextOverrides | undefined,
|
|
63
|
+
): RenderContextOverrides {
|
|
64
|
+
return { brand: { ...defaults?.brand, ...overrides?.brand } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render a notification to one channel: compose the kind's content, then let the
|
|
69
|
+
* renderer say it.
|
|
70
|
+
*
|
|
71
|
+
* Generic in `K` so a caller with a literal kind is held to that kind's payload,
|
|
72
|
+
* and in `Out` so the renderer's natural type comes back — `RenderedEmail` for
|
|
73
|
+
* email, whatever the next channel returns for the next channel. Forcing a
|
|
74
|
+
* common output shape would recreate the fusion this layer unpicks.
|
|
75
|
+
*
|
|
76
|
+
* @param context Pin it for reproducible bytes. Omitted, it is built from the
|
|
77
|
+
* kind's `defaults` and today's date — the one clock read.
|
|
78
|
+
*/
|
|
79
|
+
export function renderToChannel<K extends NotificationKind, Out>(
|
|
80
|
+
kind: K,
|
|
81
|
+
payload: NotificationPayloads[K],
|
|
82
|
+
renderer: Renderer<Out>,
|
|
83
|
+
context?: RenderContext,
|
|
84
|
+
): Out {
|
|
85
|
+
const definition = NOTIFICATION_DEFINITIONS[kind];
|
|
86
|
+
const resolved = context ?? createRenderContext(definition.defaults);
|
|
87
|
+
return renderer.render(definition.compose(payload, resolved), resolved);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Render a notification as an email.
|
|
92
|
+
*
|
|
93
|
+
* The third argument is `RenderContextOverrides`, not options: there is nothing
|
|
94
|
+
* left to opt into. The subject is `compose`'s `metadata.title`, and request
|
|
95
|
+
* metadata is decided by what the payload carries.
|
|
96
|
+
*/
|
|
97
|
+
export function renderEmail<K extends NotificationKind>(
|
|
98
|
+
kind: K,
|
|
99
|
+
payload: NotificationPayloads[K],
|
|
100
|
+
overrides?: RenderContextOverrides,
|
|
101
|
+
): RenderedEmail {
|
|
102
|
+
const context = createRenderContext(
|
|
103
|
+
layerOverrides(NOTIFICATION_DEFINITIONS[kind].defaults, overrides),
|
|
104
|
+
);
|
|
105
|
+
return renderToChannel(kind, payload, emailRenderer, context);
|
|
106
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The channel seam (ADR-CONTRACTS-086).
|
|
3
|
+
*
|
|
4
|
+
* A `Renderer` turns channel-agnostic `NotificationContent` into one channel's
|
|
5
|
+
* bytes. Email becomes one renderer rather than the shape of the whole layer.
|
|
6
|
+
*
|
|
7
|
+
* Two things are deliberate here.
|
|
8
|
+
*
|
|
9
|
+
* **Capability lives on the renderer, not the content.** `supports` asks a
|
|
10
|
+
* renderer what it can depict. The alternative — tagging elements with the
|
|
11
|
+
* channels they apply to — makes every new channel a rewrite of the content, and
|
|
12
|
+
* puts knowledge of the channel back inside the thing that is supposed to be
|
|
13
|
+
* channel-agnostic. This also superseded the `plainTextRequired` /
|
|
14
|
+
* `htmlSupported` flags on the old `EmailKindDefinition`, which put capability on
|
|
15
|
+
* the kind: the kind is not what can or cannot draw an image.
|
|
16
|
+
*
|
|
17
|
+
* **Each renderer returns its natural type.** `Out` is `{ subject, text, html }`
|
|
18
|
+
* for email and something else entirely for the next channel. Forcing a common
|
|
19
|
+
* output shape would recreate the fusion this layer unpicked — the old `Block`,
|
|
20
|
+
* which carried `html` and `text` together and would have carried a third
|
|
21
|
+
* surface bolted on.
|
|
22
|
+
*
|
|
23
|
+
* Delivery and policy stay OUT of contracts: a renderer renders, it does not
|
|
24
|
+
* send, and nothing here decides whether to.
|
|
25
|
+
*
|
|
26
|
+
* INVARIANTS:
|
|
27
|
+
* - Pure — `render` is a function of `(content, context)` and nothing else. No
|
|
28
|
+
* clock, no environment, no I/O. Ambient state arrives via `RenderContext`.
|
|
29
|
+
* - `supports` is total over `NotificationElementType` and must agree with
|
|
30
|
+
* `render`: if it answers `false`, `render` MUST omit that element rather than
|
|
31
|
+
* throw or approximate it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { NotificationContent, NotificationElementType } from "./content";
|
|
35
|
+
import type { RenderContext } from "./context";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A channel's renderer. `Out` is whatever that channel's bytes look like.
|
|
39
|
+
*
|
|
40
|
+
* Members are arrow properties, not method shorthand: the vocabulary-guard reads
|
|
41
|
+
* method signatures as behaviour smuggled into the vocabulary.
|
|
42
|
+
*/
|
|
43
|
+
export interface Renderer<Out> {
|
|
44
|
+
/** Stable channel identity, e.g. "email". */
|
|
45
|
+
id: string;
|
|
46
|
+
/** Whether this channel can depict the given element. Must be total. */
|
|
47
|
+
supports: (elementType: NotificationElementType) => boolean;
|
|
48
|
+
/** Render the whole notification. Pure. */
|
|
49
|
+
render: (content: NotificationContent, context: RenderContext) => Out;
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# notifications/renderers/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
One directory per channel (ADR-CONTRACTS-086). A renderer turns
|
|
6
|
+
channel-agnostic `NotificationContent` into one channel's bytes, and is the
|
|
7
|
+
only place in this package that knows what a sentence looks like.
|
|
8
|
+
|
|
9
|
+
- `email/` — `Renderer<RenderedEmail>`, the first and richest channel. It is the
|
|
10
|
+
old `src/email/render` layer relocated behind the seam, and — now that the
|
|
11
|
+
original is deleted — what replaced it: email demoted from "the shape of the
|
|
12
|
+
whole layer" to one channel among several.
|
|
13
|
+
- `sms/` — `Renderer<string>`, a non-functional placeholder. The poorest channel
|
|
14
|
+
the vocabulary will meet: no markup, no images, no layout.
|
|
15
|
+
- `slack/` — `Renderer<SlackMessage>`, a non-functional placeholder. A minimal
|
|
16
|
+
Block Kit tree.
|
|
17
|
+
|
|
18
|
+
A channel is added by writing a directory here — not by editing `../content.ts`,
|
|
19
|
+
`../definition.ts` or any kind. That is the property the seam exists for, and the
|
|
20
|
+
measure of whether it holds.
|
|
21
|
+
|
|
22
|
+
`sms/` and `slack/` are that measure, cashed. Email could satisfy `Renderer` by
|
|
23
|
+
construction — the seam was drawn around it, so it proves nothing on its own.
|
|
24
|
+
Adding two channels that are nothing like it, and adding them **without touching a
|
|
25
|
+
single element, kind or definition**, is what turns the claim above from an
|
|
26
|
+
intention into a fact. They are placeholders on purpose: they do not send, and
|
|
27
|
+
their copy is invented rather than relocated. Their SHAPE is not provisional.
|
|
28
|
+
|
|
29
|
+
Three channels now answer `supports` differently over one content that carries no
|
|
30
|
+
channel tags — email declines nothing, slack declines `chatUnit`, sms also
|
|
31
|
+
declines `heroImage` and `divider` — and `Out` is a record, then a string, then a
|
|
32
|
+
different record. Those are this layer's two design claims, stated by real modules
|
|
33
|
+
rather than by test doubles.
|
|
34
|
+
|
|
35
|
+
## Invariants
|
|
36
|
+
|
|
37
|
+
- `render` is PURE — a function of `(content, context)`. No clock (the year comes
|
|
38
|
+
from `RenderContext`), no environment, no I/O. `../context.ts` owns the only
|
|
39
|
+
clock read in the domain.
|
|
40
|
+
- `supports` is total over `NotificationElementType` and MUST agree with
|
|
41
|
+
`render`: an element it declines is OMITTED, never approximated and never
|
|
42
|
+
thrown on.
|
|
43
|
+
- Each renderer returns its NATURAL type. There is no `ChannelOutput` supertype —
|
|
44
|
+
forcing a common shape would recreate the `html`+`text` fusion this layer is
|
|
45
|
+
unpicking, with a third surface bolted on.
|
|
46
|
+
- A renderer renders. It does not send, and it does not decide whether to —
|
|
47
|
+
delivery and policy are the backend's.
|
|
48
|
+
- Markup, styling and spacing live HERE and nowhere else. If a renderer needs a
|
|
49
|
+
fact the content does not carry, the fix is upstream in `compose`; the moment a
|
|
50
|
+
renderer starts special-casing a `metadata.kind`, the model has lost.
|