@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
@@ -1,194 +0,0 @@
1
- /**
2
- * Central email dispatcher. Given a kind + payload it returns
3
- * `{ subject, text, html }`, pulling the subject from the registry (single
4
- * source of truth) and deriving both surfaces from ONE composed block list
5
- * (`htmlShell` + `textShell`). This is the one entry point both the backend
6
- * (real sends) and the app (Ladle preview) call.
7
- */
8
-
9
- import { EMAIL_KINDS } from "../registry";
10
- import type { EmailKind, EmailPayloads } from "../types";
11
-
12
- import { renderAuthOtp } from "./auth-otp";
13
- import { type Block, htmlShell, textShell } from "./blocks";
14
- import { renderAccessApproved } from "./company-md-access-approved";
15
- import { renderAccessDenied } from "./company-md-access-denied";
16
- import { renderAccessRequested } from "./company-md-access-requested";
17
- import { renderChatShared } from "./chat-shared";
18
- import { renderOrgInvite } from "./org-invite";
19
- import { renderOwnershipTransfer } from "./ownership-transfer";
20
- import { renderOwnershipTransferCompleted } from "./ownership-transfer-completed";
21
- import { renderSecurityAlert } from "./security-alert";
22
- import { renderShareGranted } from "./share-granted";
23
- import { renderUnitOwnerGranted } from "./unit-owner-granted";
24
-
25
- /** Rendered email output. */
26
- export interface RenderedEmail {
27
- /** Subject line (from the registry). */
28
- subject: string;
29
- /** Plain-text body. */
30
- text: string;
31
- /** HTML body (every kind is dual-output). */
32
- html: string;
33
- }
34
-
35
- /** Caller-supplied render options. */
36
- export interface RenderEmailOptions {
37
- /** Include OTP request IP / device details (PII; backend gates via env). */
38
- includeRequestMetadata?: boolean;
39
- }
40
-
41
- /** The email kinds with a render implementation. */
42
- export const IMPLEMENTED_EMAIL_KINDS = [
43
- "auth.otp",
44
- "org.invite",
45
- "org.unit_owner_granted",
46
- "org.ownership_transfer",
47
- "org.ownership_transfer_completed",
48
- "security.alert",
49
- "chat.shared",
50
- "share.granted",
51
- "companyMd.access_requested",
52
- "companyMd.access_request_approved",
53
- "companyMd.access_request_denied",
54
- ] as const satisfies readonly EmailKind[];
55
-
56
- export type ImplementedEmailKind = (typeof IMPLEMENTED_EMAIL_KINDS)[number];
57
-
58
- /** Payload type for a kind (or `never` for kinds without a payload). */
59
- type PayloadFor<K extends EmailKind> = K extends keyof EmailPayloads
60
- ? EmailPayloads[K]
61
- : never;
62
-
63
- function toEmail(subject: string, blocks: Block[]): RenderedEmail {
64
- return { subject, text: textShell(blocks), html: htmlShell(blocks) };
65
- }
66
-
67
- /**
68
- * Fill `{field}` placeholders in a registry subject from the payload (e.g.
69
- * `{orgName}` → the payload's orgName). Unmatched/absent placeholders are left
70
- * verbatim, and a placeholder-free subject passes through untouched.
71
- */
72
- function resolveSubject(template: string, payload: unknown): string {
73
- if (!payload || typeof payload !== "object") return template;
74
- const data = payload as Record<string, unknown>;
75
- return template.replace(/\{(\w+)\}/g, (whole, key) =>
76
- data[key] != null ? String(data[key]) : whole,
77
- );
78
- }
79
-
80
- /**
81
- * Per-kind subject data. Most kinds resolve directly against the payload; a few
82
- * expose a derived token the payload doesn't carry. `org.unit_owner_granted`
83
- * needs a short role word ("owner"/"delegate") for the subject, whereas the
84
- * payload's `roleLabel` is the display form ("Unit owner"/"Delegate") used in
85
- * the body's Role row — so it's derived here rather than mutating roleLabel.
86
- */
87
- function subjectData(kind: EmailKind, payload: unknown): unknown {
88
- if (
89
- kind === "org.unit_owner_granted" &&
90
- payload &&
91
- typeof payload === "object"
92
- ) {
93
- const roleLabel = (payload as { roleLabel?: string }).roleLabel;
94
- return {
95
- ...payload,
96
- roleWord: roleLabel === "Delegate" ? "delegate owner" : "owner",
97
- };
98
- }
99
- return payload;
100
- }
101
-
102
- /**
103
- * Render an email by kind. Subject comes from `EMAIL_KINDS`; both surfaces
104
- * derive from one composed block list. Accepts the full `EmailKind` union so
105
- * callers with a generic kind (e.g. the backend `EmailService`) type cleanly;
106
- * unimplemented kinds throw at runtime.
107
- *
108
- * @throws if the kind has no implementation.
109
- */
110
- export function renderEmail<K extends EmailKind>(
111
- kind: K,
112
- payload: PayloadFor<K>,
113
- options?: RenderEmailOptions,
114
- ): RenderedEmail {
115
- const subject = resolveSubject(
116
- EMAIL_KINDS[kind].subject,
117
- subjectData(kind, payload),
118
- );
119
-
120
- switch (kind) {
121
- case "auth.otp":
122
- return toEmail(
123
- subject,
124
- renderAuthOtp(payload as EmailPayloads["auth.otp"], options),
125
- );
126
- case "org.invite":
127
- return toEmail(
128
- subject,
129
- renderOrgInvite(payload as EmailPayloads["org.invite"]),
130
- );
131
- case "org.unit_owner_granted":
132
- return toEmail(
133
- subject,
134
- renderUnitOwnerGranted(
135
- payload as EmailPayloads["org.unit_owner_granted"],
136
- ),
137
- );
138
- case "org.ownership_transfer":
139
- return toEmail(
140
- subject,
141
- renderOwnershipTransfer(
142
- payload as EmailPayloads["org.ownership_transfer"],
143
- ),
144
- );
145
- case "org.ownership_transfer_completed":
146
- return toEmail(
147
- subject,
148
- renderOwnershipTransferCompleted(
149
- payload as EmailPayloads["org.ownership_transfer_completed"],
150
- ),
151
- );
152
- case "security.alert":
153
- return toEmail(
154
- subject,
155
- renderSecurityAlert(payload as EmailPayloads["security.alert"]),
156
- );
157
- case "chat.shared":
158
- return toEmail(
159
- subject,
160
- renderChatShared(payload as EmailPayloads["chat.shared"]),
161
- );
162
- case "share.granted":
163
- return toEmail(
164
- subject,
165
- renderShareGranted(payload as EmailPayloads["share.granted"]),
166
- );
167
- case "companyMd.access_requested":
168
- return toEmail(
169
- subject,
170
- renderAccessRequested(
171
- payload as EmailPayloads["companyMd.access_requested"],
172
- ),
173
- );
174
- case "companyMd.access_request_approved":
175
- return toEmail(
176
- subject,
177
- renderAccessApproved(
178
- payload as EmailPayloads["companyMd.access_request_approved"],
179
- ),
180
- );
181
- case "companyMd.access_request_denied":
182
- return toEmail(
183
- subject,
184
- renderAccessDenied(
185
- payload as EmailPayloads["companyMd.access_request_denied"],
186
- ),
187
- );
188
- default: {
189
- // Reachable for registered-but-unimplemented kinds (e.g. auth.magic_link).
190
- const unimplemented: string = kind;
191
- throw new Error(`Email kind not implemented: ${unimplemented}`);
192
- }
193
- }
194
- }
@@ -1,61 +0,0 @@
1
- /**
2
- * Security alert email (plain text only).
3
- */
4
-
5
- import type { EmailPayloads } from "../types";
6
-
7
- import { type Block, keyValue, paragraph, security, signature } from "./blocks";
8
-
9
- export type SecurityAlertPayload = EmailPayloads["security.alert"];
10
- export type SecurityAlertType = SecurityAlertPayload["alertType"];
11
-
12
- export const SECURITY_ALERT_TYPES = [
13
- "excessive_otp_requests",
14
- "unusual_login_location",
15
- ] as const;
16
-
17
- export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
18
- const { alertType, details, timestamp } = payload;
19
-
20
- const blocks: Block[] = [security()];
21
-
22
- switch (alertType) {
23
- case "excessive_otp_requests":
24
- blocks.push(
25
- paragraph("Unusual login-code activity detected."),
26
- keyValue("Details", details, "normal"),
27
- paragraph("If this was you, no action is needed.", "tight"),
28
- paragraph(
29
- "If you didn't request these codes, someone may be trying to access your account.",
30
- "normal",
31
- ),
32
- paragraph("We recommend reviewing your account security."),
33
- );
34
- break;
35
-
36
- case "unusual_login_location":
37
- blocks.push(
38
- paragraph("A login from an unusual location was detected."),
39
- keyValue("Details", details, "normal"),
40
- paragraph("If this was you, no action is needed.", "tight"),
41
- paragraph(
42
- "If you didn't attempt to log in, someone may be trying to access your account.",
43
- "normal",
44
- ),
45
- paragraph("We recommend reviewing your account security."),
46
- );
47
- break;
48
-
49
- default: {
50
- const _exhaustive: never = alertType;
51
- throw new Error(`Unknown alert type: ${_exhaustive}`);
52
- }
53
- }
54
-
55
- blocks.push(
56
- keyValue("Time", new Date(timestamp).toUTCString(), "none"),
57
- signature(),
58
- );
59
-
60
- return blocks;
61
- }
@@ -1,52 +0,0 @@
1
- /**
2
- * Share-granted email (an entity was shared with the recipient).
3
- */
4
-
5
- import type { EmailPayloads } from "../types";
6
-
7
- import {
8
- ACCESS_PHRASE,
9
- type Block,
10
- footer,
11
- greeting,
12
- keyValue,
13
- NOTICE,
14
- paragraph,
15
- signature,
16
- } from "./blocks";
17
- import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
18
- import { COMPANY_NAME } from "./constants";
19
-
20
- export type ShareGrantedPayload = EmailPayloads["share.granted"];
21
-
22
- export function renderShareGranted(payload: ShareGrantedPayload): Block[] {
23
- const {
24
- granterName,
25
- recipientName,
26
- entityLabel,
27
- entityTitle,
28
- accessLevel,
29
- ctaUrl,
30
- message,
31
- } = payload;
32
-
33
- const blocks: Block[] = [
34
- greeting(recipientName),
35
- paragraph(`A ${entityLabel} was shared with you.`),
36
- chatUnit(
37
- message ? chatUser(message, granterName) : chatAssistant("Open to view."),
38
- chatCta({ label: "OPEN", href: ctaUrl }),
39
- ),
40
- keyValue("From", granterName),
41
- ];
42
-
43
- if (entityTitle) blocks.push(keyValue("Item", `"${entityTitle}"`));
44
-
45
- blocks.push(
46
- keyValue("Access", ACCESS_PHRASE[accessLevel], "normal"),
47
- footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
48
- signature(),
49
- );
50
-
51
- return blocks;
52
- }
@@ -1,60 +0,0 @@
1
- /**
2
- * Unit-owner-granted email (added to a team's unit ownership).
3
- */
4
-
5
- import type { EmailPayloads } from "../types";
6
-
7
- import {
8
- type Block,
9
- footer,
10
- formatExpiry,
11
- greeting,
12
- keyValue,
13
- NOTICE,
14
- paragraph,
15
- signature,
16
- titleCase,
17
- } from "./blocks";
18
- import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
19
- import { COMPANY_NAME } from "./constants";
20
-
21
- export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
22
-
23
- export function renderUnitOwnerGranted(
24
- payload: UnitOwnerGrantedPayload,
25
- ): Block[] {
26
- const {
27
- granterName,
28
- recipientName,
29
- unitName,
30
- roleLabel,
31
- ctaUrl,
32
- message,
33
- expiresAt,
34
- } = payload;
35
- const roleNoun = roleLabel === "Delegate" ? "delegate owner" : "unit owner";
36
-
37
- return [
38
- greeting(recipientName),
39
- paragraph(
40
- `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.`,
41
- ),
42
- chatUnit(
43
- ...(message ? [chatUser(message, granterName)] : []),
44
- chatAssistant("Open the team to get started."),
45
- chatCta({ label: "MANAGE TEAM", href: ctaUrl }),
46
- ),
47
- keyValue("From", granterName),
48
- keyValue("Team", unitName),
49
- keyValue(
50
- "Role",
51
- titleCase(roleLabel),
52
- expiresAt != null ? "tight" : "normal",
53
- ),
54
- ...(expiresAt != null
55
- ? [keyValue("Expires", formatExpiry(expiresAt), "normal")]
56
- : []),
57
- footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
58
- signature(),
59
- ];
60
- }