@company-semantics/contracts 27.14.0 → 28.1.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.
@@ -1,39 +1,26 @@
1
1
  /**
2
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). This is the one entry point both the backend (real sends)
5
- * and the app (Ladle preview) call.
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.
6
7
  */
7
8
 
8
9
  import { EMAIL_KINDS } from "../registry";
9
10
  import type { EmailKind, EmailPayloads } from "../types";
10
11
 
11
- import { renderAuthOtpBody, renderAuthOtpHtml } from "./auth-otp";
12
- import {
13
- renderAccessApprovedBody,
14
- renderAccessApprovedHtml,
15
- } from "./company-md-access-approved";
16
- import {
17
- renderAccessDeniedBody,
18
- renderAccessDeniedHtml,
19
- } from "./company-md-access-denied";
20
- import {
21
- renderAccessRequestedBody,
22
- renderAccessRequestedHtml,
23
- } from "./company-md-access-requested";
24
- import { renderChatSharedBody, renderChatSharedHtml } from "./chat-shared";
25
- import { renderOrgInviteBody, renderOrgInviteHtml } from "./org-invite";
26
- import { renderOwnershipTransferBody } from "./ownership-transfer";
27
- import { renderOwnershipTransferCompletedBody } from "./ownership-transfer-completed";
28
- import { renderSecurityAlertBody } from "./security-alert";
29
- import {
30
- renderShareGrantedBody,
31
- renderShareGrantedHtml,
32
- } from "./share-granted";
33
- import {
34
- renderUnitOwnerGrantedBody,
35
- renderUnitOwnerGrantedHtml,
36
- } from "./unit-owner-granted";
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";
37
24
 
38
25
  /** Rendered email output. */
39
26
  export interface RenderedEmail {
@@ -41,8 +28,8 @@ export interface RenderedEmail {
41
28
  subject: string;
42
29
  /** Plain-text body. */
43
30
  text: string;
44
- /** HTML body (present only for html-supported kinds). */
45
- html?: string;
31
+ /** HTML body (every kind is dual-output). */
32
+ html: string;
46
33
  }
47
34
 
48
35
  /** Caller-supplied render options. */
@@ -73,11 +60,15 @@ type PayloadFor<K extends EmailKind> = K extends keyof EmailPayloads
73
60
  ? EmailPayloads[K]
74
61
  : never;
75
62
 
63
+ function toEmail(subject: string, blocks: Block[]): RenderedEmail {
64
+ return { subject, text: textShell(blocks), html: htmlShell(blocks) };
65
+ }
66
+
76
67
  /**
77
- * Render an email by kind. Subject comes from `EMAIL_KINDS`; `html` is present
78
- * only when the template emits an HTML variant. Accepts the full `EmailKind`
79
- * union so callers with a generic kind (e.g. the backend `EmailService`) type
80
- * cleanly; unimplemented kinds throw at runtime.
68
+ * Render an email by kind. Subject comes from `EMAIL_KINDS`; both surfaces
69
+ * derive from one composed block list. Accepts the full `EmailKind` union so
70
+ * callers with a generic kind (e.g. the backend `EmailService`) type cleanly;
71
+ * unimplemented kinds throw at runtime.
81
72
  *
82
73
  * @throws if the kind has no implementation.
83
74
  */
@@ -89,82 +80,73 @@ export function renderEmail<K extends EmailKind>(
89
80
  const subject = EMAIL_KINDS[kind].subject;
90
81
 
91
82
  switch (kind) {
92
- case "auth.otp": {
93
- const p = payload as EmailPayloads["auth.otp"];
94
- return {
83
+ case "auth.otp":
84
+ return toEmail(
95
85
  subject,
96
- text: renderAuthOtpBody(p, options),
97
- html: renderAuthOtpHtml(p),
98
- };
99
- }
100
- case "org.invite": {
101
- const p = payload as EmailPayloads["org.invite"];
102
- return {
86
+ renderAuthOtp(payload as EmailPayloads["auth.otp"], options),
87
+ );
88
+ case "org.invite":
89
+ return toEmail(
103
90
  subject,
104
- text: renderOrgInviteBody(p),
105
- html: renderOrgInviteHtml(p),
106
- };
107
- }
108
- case "org.unit_owner_granted": {
109
- const p = payload as EmailPayloads["org.unit_owner_granted"];
110
- return {
91
+ renderOrgInvite(payload as EmailPayloads["org.invite"]),
92
+ );
93
+ case "org.unit_owner_granted":
94
+ return toEmail(
111
95
  subject,
112
- text: renderUnitOwnerGrantedBody(p),
113
- html: renderUnitOwnerGrantedHtml(p),
114
- };
115
- }
116
- case "org.ownership_transfer": {
117
- const p = payload as EmailPayloads["org.ownership_transfer"];
118
- return { subject, text: renderOwnershipTransferBody(p) };
119
- }
120
- case "org.ownership_transfer_completed": {
121
- const p = payload as EmailPayloads["org.ownership_transfer_completed"];
122
- return { subject, text: renderOwnershipTransferCompletedBody(p) };
123
- }
124
- case "security.alert": {
125
- const p = payload as EmailPayloads["security.alert"];
126
- return { subject, text: renderSecurityAlertBody(p) };
127
- }
128
- case "chat.shared": {
129
- const p = payload as EmailPayloads["chat.shared"];
130
- return {
96
+ renderUnitOwnerGranted(
97
+ payload as EmailPayloads["org.unit_owner_granted"],
98
+ ),
99
+ );
100
+ case "org.ownership_transfer":
101
+ return toEmail(
131
102
  subject,
132
- text: renderChatSharedBody(p),
133
- html: renderChatSharedHtml(p),
134
- };
135
- }
136
- case "share.granted": {
137
- const p = payload as EmailPayloads["share.granted"];
138
- return {
103
+ renderOwnershipTransfer(
104
+ payload as EmailPayloads["org.ownership_transfer"],
105
+ ),
106
+ );
107
+ case "org.ownership_transfer_completed":
108
+ return toEmail(
139
109
  subject,
140
- text: renderShareGrantedBody(p),
141
- html: renderShareGrantedHtml(p),
142
- };
143
- }
144
- case "companyMd.access_requested": {
145
- const p = payload as EmailPayloads["companyMd.access_requested"];
146
- return {
110
+ renderOwnershipTransferCompleted(
111
+ payload as EmailPayloads["org.ownership_transfer_completed"],
112
+ ),
113
+ );
114
+ case "security.alert":
115
+ return toEmail(
147
116
  subject,
148
- text: renderAccessRequestedBody(p),
149
- html: renderAccessRequestedHtml(p),
150
- };
151
- }
152
- case "companyMd.access_request_approved": {
153
- const p = payload as EmailPayloads["companyMd.access_request_approved"];
154
- return {
117
+ renderSecurityAlert(payload as EmailPayloads["security.alert"]),
118
+ );
119
+ case "chat.shared":
120
+ return toEmail(
155
121
  subject,
156
- text: renderAccessApprovedBody(p),
157
- html: renderAccessApprovedHtml(p),
158
- };
159
- }
160
- case "companyMd.access_request_denied": {
161
- const p = payload as EmailPayloads["companyMd.access_request_denied"];
162
- return {
122
+ renderChatShared(payload as EmailPayloads["chat.shared"]),
123
+ );
124
+ case "share.granted":
125
+ return toEmail(
163
126
  subject,
164
- text: renderAccessDeniedBody(p),
165
- html: renderAccessDeniedHtml(p),
166
- };
167
- }
127
+ renderShareGranted(payload as EmailPayloads["share.granted"]),
128
+ );
129
+ case "companyMd.access_requested":
130
+ return toEmail(
131
+ subject,
132
+ renderAccessRequested(
133
+ payload as EmailPayloads["companyMd.access_requested"],
134
+ ),
135
+ );
136
+ case "companyMd.access_request_approved":
137
+ return toEmail(
138
+ subject,
139
+ renderAccessApproved(
140
+ payload as EmailPayloads["companyMd.access_request_approved"],
141
+ ),
142
+ );
143
+ case "companyMd.access_request_denied":
144
+ return toEmail(
145
+ subject,
146
+ renderAccessDenied(
147
+ payload as EmailPayloads["companyMd.access_request_denied"],
148
+ ),
149
+ );
168
150
  default: {
169
151
  // Reachable for registered-but-unimplemented kinds (e.g. auth.magic_link).
170
152
  const unimplemented: string = kind;
@@ -1,13 +1,10 @@
1
1
  /**
2
- * Security alert email (plain text only).
3
- *
4
- * Uses a single shared footer via `baseTextLayout` with a "Security" signer —
5
- * the previous copy hand-rolled a second footer, printing two.
2
+ * Security alert email (plain text only). Signs off with a "Security" signer.
6
3
  */
7
4
 
8
5
  import type { EmailPayloads } from "../types";
9
6
 
10
- import { baseTextLayout } from "./base-text";
7
+ import { type Block, keyValue, paragraph, signature } from "./blocks";
11
8
  import { COMPANY_NAME } from "./constants";
12
9
 
13
10
  export type SecurityAlertPayload = EmailPayloads["security.alert"];
@@ -18,34 +15,35 @@ export const SECURITY_ALERT_TYPES = [
18
15
  "unusual_login_location",
19
16
  ] as const;
20
17
 
21
- export function renderSecurityAlertBody(payload: SecurityAlertPayload): string {
18
+ export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
22
19
  const { alertType, details, timestamp } = payload;
23
20
 
24
- const sections: string[] = [];
21
+ const blocks: Block[] = [];
25
22
 
26
23
  switch (alertType) {
27
24
  case "excessive_otp_requests":
28
- sections.push(
29
- "We detected an unusual number of login code requests for your account.",
25
+ blocks.push(
26
+ paragraph(
27
+ "We detected an unusual number of login code requests for your account.",
28
+ ),
29
+ keyValue("Details", details, "normal"),
30
+ paragraph("If this was you, no action is needed.", "tight"),
31
+ paragraph(
32
+ "If you didn't request these codes, someone may be trying to access your account.",
33
+ "tight",
34
+ ),
35
+ paragraph("We recommend reviewing your account security."),
30
36
  );
31
- sections.push("");
32
- sections.push(`Details: ${details}`);
33
- sections.push("");
34
- sections.push("If this was you, no action is needed.");
35
- sections.push(
36
- "If you didn't request these codes, someone may be trying to access your account.",
37
- );
38
- sections.push("We recommend reviewing your account security.");
39
37
  break;
40
38
 
41
39
  case "unusual_login_location":
42
- sections.push("We detected a login attempt from an unusual location.");
43
- sections.push("");
44
- sections.push(`Details: ${details}`);
45
- sections.push("");
46
- sections.push("If this was you, no action is needed.");
47
- sections.push(
48
- "If you didn't attempt to log in, please secure your account immediately.",
40
+ blocks.push(
41
+ paragraph("We detected a login attempt from an unusual location."),
42
+ keyValue("Details", details, "normal"),
43
+ paragraph("If this was you, no action is needed.", "tight"),
44
+ paragraph(
45
+ "If you didn't attempt to log in, please secure your account immediately.",
46
+ ),
49
47
  );
50
48
  break;
51
49
 
@@ -55,8 +53,10 @@ export function renderSecurityAlertBody(payload: SecurityAlertPayload): string {
55
53
  }
56
54
  }
57
55
 
58
- sections.push("");
59
- sections.push(`Time: ${new Date(timestamp).toUTCString()}`);
56
+ blocks.push(
57
+ keyValue("Time", new Date(timestamp).toUTCString(), "normal"),
58
+ signature(`${COMPANY_NAME} Security`),
59
+ );
60
60
 
61
- return baseTextLayout(sections.join("\n"), `${COMPANY_NAME} Security`);
61
+ return blocks;
62
62
  }
@@ -1,62 +1,27 @@
1
1
  /**
2
- * Share-granted email (an entity was shared with the recipient). Text + HTML.
2
+ * Share-granted email (an entity was shared with the recipient).
3
3
  */
4
4
 
5
5
  import type { EmailPayloads } from "../types";
6
6
 
7
- import { baseTextLayout } from "./base-text";
8
7
  import {
9
8
  ACCESS_PHRASE,
10
- asciiCtaBox,
11
- paragraph,
9
+ type Block,
10
+ bold,
11
+ chatUser,
12
12
  ctaBox,
13
13
  footer,
14
14
  greeting,
15
- htmlShell,
15
+ type Inline,
16
16
  NOTICE,
17
- chatUserBubble,
18
- chatUserBoxText,
17
+ paragraph,
19
18
  signature,
20
19
  } from "./blocks";
21
20
  import { COMPANY_NAME } from "./constants";
22
- import { escapeHtml } from "./escape-html";
23
21
 
24
22
  export type ShareGrantedPayload = EmailPayloads["share.granted"];
25
23
 
26
- /** `the document "Roadmap"` or `a document` when there is no title. */
27
- function describeEntity(payload: ShareGrantedPayload): string {
28
- return payload.entityTitle
29
- ? `the ${payload.entityLabel} "${payload.entityTitle}"`
30
- : `a ${payload.entityLabel}`;
31
- }
32
-
33
- export function renderShareGrantedBody(payload: ShareGrantedPayload): string {
34
- const { granterName, recipientName, accessLevel, ctaUrl, message } = payload;
35
-
36
- const sections: string[] = [];
37
- sections.push(recipientName ? `Hi ${recipientName},` : "Hi,");
38
- sections.push("");
39
- sections.push(
40
- `${granterName} shared ${describeEntity(payload)} with you — you ${ACCESS_PHRASE[accessLevel]}.`,
41
- );
42
-
43
- if (message) {
44
- sections.push("");
45
- sections.push(chatUserBoxText(message, `Message from ${granterName}`));
46
- }
47
-
48
- sections.push("");
49
- sections.push(asciiCtaBox("OPEN"));
50
- sections.push("");
51
- sections.push(ctaUrl);
52
- sections.push("");
53
- sections.push(`This notification was sent via ${COMPANY_NAME}.`);
54
- sections.push(NOTICE);
55
-
56
- return baseTextLayout(sections.join("\n"));
57
- }
58
-
59
- export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
24
+ export function renderShareGranted(payload: ShareGrantedPayload): Block[] {
60
25
  const {
61
26
  granterName,
62
27
  recipientName,
@@ -67,16 +32,23 @@ export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
67
32
  message,
68
33
  } = payload;
69
34
 
70
- const entityHtml = entityTitle
71
- ? `the ${escapeHtml(entityLabel)} <b>"${escapeHtml(entityTitle)}"</b>`
72
- : `a ${escapeHtml(entityLabel)}`;
35
+ const entity: Array<string | Inline> = entityTitle
36
+ ? [`the ${entityLabel} `, bold(`"${entityTitle}"`)]
37
+ : [`a ${entityLabel}`];
73
38
 
74
- return htmlShell([
39
+ const blocks: Block[] = [
75
40
  greeting(recipientName),
76
- paragraph(
77
- `<b>${escapeHtml(granterName)}</b> shared ${entityHtml} with you &mdash; you ${ACCESS_PHRASE[accessLevel]}.`,
78
- ),
79
- message ? chatUserBubble(message, `Message from ${granterName}`) : "",
41
+ paragraph([
42
+ bold(granterName),
43
+ " shared ",
44
+ ...entity,
45
+ ` with you — you ${ACCESS_PHRASE[accessLevel]}.`,
46
+ ]),
47
+ ];
48
+
49
+ if (message) blocks.push(chatUser(message, `Message from ${granterName}`));
50
+
51
+ blocks.push(
80
52
  ctaBox({
81
53
  label: "OPEN",
82
54
  href: ctaUrl,
@@ -86,5 +58,7 @@ export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
86
58
  }),
87
59
  footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
88
60
  signature(),
89
- ]);
61
+ );
62
+
63
+ return blocks;
90
64
  }
@@ -1,66 +1,45 @@
1
1
  /**
2
- * Unit-owner-granted email (added to a team's unit ownership). Text + HTML.
2
+ * Unit-owner-granted email (added to a team's unit ownership).
3
3
  */
4
4
 
5
5
  import type { EmailPayloads } from "../types";
6
6
 
7
- import { baseTextLayout } from "./base-text";
8
7
  import {
9
- asciiCtaBox,
10
- paragraph,
8
+ type Block,
9
+ bold,
10
+ chatUser,
11
11
  ctaBox,
12
12
  footer,
13
13
  greeting,
14
- htmlShell,
15
14
  NOTICE,
16
- chatUserBubble,
17
- chatUserBoxText,
15
+ paragraph,
18
16
  signature,
19
17
  } from "./blocks";
20
18
  import { COMPANY_NAME } from "./constants";
21
- import { escapeHtml } from "./escape-html";
22
19
 
23
20
  export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
24
21
 
25
- export function renderUnitOwnerGrantedBody(
26
- payload: UnitOwnerGrantedPayload,
27
- ): string {
28
- const { granterName, recipientName, unitName, roleLabel, ctaUrl, message } =
29
- payload;
30
-
31
- const sections: string[] = [];
32
- sections.push(recipientName ? `Hi ${recipientName},` : "Hi,");
33
- sections.push("");
34
- sections.push(`${granterName} added you as a ${roleLabel} of "${unitName}".`);
35
-
36
- if (message) {
37
- sections.push("");
38
- sections.push(chatUserBoxText(message, `Message from ${granterName}`));
39
- }
40
-
41
- sections.push("");
42
- sections.push(asciiCtaBox("VIEW TEAM"));
43
- sections.push("");
44
- sections.push(ctaUrl);
45
- sections.push("");
46
- sections.push(`This notification was sent via ${COMPANY_NAME}.`);
47
- sections.push(NOTICE);
48
-
49
- return baseTextLayout(sections.join("\n"));
50
- }
51
-
52
- export function renderUnitOwnerGrantedHtml(
22
+ export function renderUnitOwnerGranted(
53
23
  payload: UnitOwnerGrantedPayload,
54
- ): string {
24
+ ): Block[] {
55
25
  const { granterName, recipientName, unitName, roleLabel, ctaUrl, message } =
56
26
  payload;
57
27
 
58
- return htmlShell([
28
+ const blocks: Block[] = [
59
29
  greeting(recipientName),
60
- paragraph(
61
- `<b>${escapeHtml(granterName)}</b> added you as a <b>${escapeHtml(roleLabel)}</b> of <b>${escapeHtml(unitName)}</b>.`,
62
- ),
63
- message ? chatUserBubble(message, `Message from ${granterName}`) : "",
30
+ paragraph([
31
+ bold(granterName),
32
+ " added you as a ",
33
+ bold(roleLabel),
34
+ " of ",
35
+ bold(unitName),
36
+ ".",
37
+ ]),
38
+ ];
39
+
40
+ if (message) blocks.push(chatUser(message, `Message from ${granterName}`));
41
+
42
+ blocks.push(
64
43
  ctaBox({
65
44
  label: "VIEW TEAM",
66
45
  href: ctaUrl,
@@ -70,5 +49,7 @@ export function renderUnitOwnerGrantedHtml(
70
49
  }),
71
50
  footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
72
51
  signature(),
73
- ]);
52
+ );
53
+
54
+ return blocks;
74
55
  }
@@ -12,6 +12,10 @@
12
12
  * - `ownership` — entity.owner_user_id column projection
13
13
  * - `visibility` — entity.visibility tier compiled to viewer rows
14
14
  * - `inheritance` — org-chart-membership-based unit walk
15
+ * - `authority` — org-admin / account-owner / unit-editor / sharing-policy
16
+ * bands as synthetic live-resolved principals (ADR-BE-185)
17
+ * - `context_association` — a company.md context-bank doc inherits viewer access
18
+ * from every associated parent (the governed union, ADR-BE-392)
15
19
  * - `migration` — one-shot backfill from legacy systems (AUTH-004)
16
20
  */
17
21
  import { z } from "zod";
@@ -21,6 +25,8 @@ export const ACCESS_SOURCES = [
21
25
  "ownership",
22
26
  "visibility",
23
27
  "inheritance",
28
+ "authority",
29
+ "context_association",
24
30
  "migration",
25
31
  ] as const;
26
32
  export type AccessSource = (typeof ACCESS_SOURCES)[number];
@@ -1,26 +0,0 @@
1
- /**
2
- * Plain-text email layout.
3
- */
4
-
5
- import { COMPANY_NAME, SUPPORT_EMAIL } from "./constants";
6
-
7
- /**
8
- * Wrap plain-text email content in the shared footer
9
- * (`--- / <signer> / Questions? Contact <support>`).
10
- *
11
- * @param content - Body content (without footer); trimmed.
12
- * @param signer - Footer signer name; defaults to the company name.
13
- * (`security.alert` passes "Company Semantics Security".)
14
- */
15
- export function baseTextLayout(
16
- content: string,
17
- signer: string = COMPANY_NAME,
18
- ): string {
19
- const footer = `
20
- ---
21
- ${signer}
22
- Questions? Contact ${SUPPORT_EMAIL}
23
- `;
24
-
25
- return content.trim() + "\n" + footer.trim() + "\n";
26
- }