@company-semantics/contracts 34.0.0 → 35.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.
- package/package.json +4 -4
- package/src/index.ts +17 -11
- package/src/notifications/README.md +142 -0
- package/src/notifications/__tests__/README.md +49 -0
- package/src/notifications/__tests__/__snapshots__/README.md +39 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
- 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__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +80 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -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 +78 -0
- package/src/notifications/renderers/email/__tests__/README.md +32 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
- package/src/notifications/renderers/email/constants.ts +64 -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 +238 -0
- package/src/notifications/renderers/email/shells.ts +61 -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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SMS channel is a placeholder, so what is worth pinning is NOT its wording —
|
|
3
|
+
* that is invented and expected to change when a kind is actually sent over SMS.
|
|
4
|
+
* What is pinned here is the contract a placeholder still owes: that `supports` is
|
|
5
|
+
* total, that it AGREES with `render`, that `render` is pure and reads the brand
|
|
6
|
+
* from the context, and that a content carrying all twelve element types goes
|
|
7
|
+
* through without throwing.
|
|
8
|
+
*
|
|
9
|
+
* The one assertion on exact bytes exists to prove the declines are silent — the
|
|
10
|
+
* only way to see "omitted, never approximated" is to look at the whole string.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
NotificationContent,
|
|
17
|
+
NotificationElementType,
|
|
18
|
+
} from "../../../content";
|
|
19
|
+
import type { RenderContext } from "../../../context";
|
|
20
|
+
import { smsRenderer } from "../index";
|
|
21
|
+
|
|
22
|
+
const CONTEXT: RenderContext = {
|
|
23
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Every element type, so `render` is exercised over the whole union. */
|
|
27
|
+
const CONTENT: NotificationContent = {
|
|
28
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
29
|
+
sections: [
|
|
30
|
+
{
|
|
31
|
+
elements: [
|
|
32
|
+
{ type: "heroImage", src: "https://example.test/h.png", alt: "Acme" },
|
|
33
|
+
{ type: "greeting", recipientName: "Ada" },
|
|
34
|
+
{ type: "body", text: "Acme uses Company Semantics." },
|
|
35
|
+
{
|
|
36
|
+
type: "keyValueTable",
|
|
37
|
+
rows: [{ label: "Org", value: "Acme" }],
|
|
38
|
+
},
|
|
39
|
+
{ type: "list", items: ["Read", "Write"] },
|
|
40
|
+
{ type: "divider" },
|
|
41
|
+
{ type: "warning" },
|
|
42
|
+
{
|
|
43
|
+
type: "chatUnit",
|
|
44
|
+
items: [{ type: "message", role: "user", text: "Hello?" }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "metadata",
|
|
48
|
+
label: "Request details",
|
|
49
|
+
rows: [{ label: "IP", value: "203.0.113.7" }],
|
|
50
|
+
},
|
|
51
|
+
{ type: "notice", lines: ["Not expecting this?", "No action needed."] },
|
|
52
|
+
{ type: "callToAction", label: "JOIN", href: "https://example.test/j" },
|
|
53
|
+
{ type: "signature" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ALL_TYPES: NotificationElementType[] = [
|
|
60
|
+
"greeting",
|
|
61
|
+
"body",
|
|
62
|
+
"keyValueTable",
|
|
63
|
+
"callToAction",
|
|
64
|
+
"list",
|
|
65
|
+
"divider",
|
|
66
|
+
"warning",
|
|
67
|
+
"metadata",
|
|
68
|
+
"notice",
|
|
69
|
+
"chatUnit",
|
|
70
|
+
"signature",
|
|
71
|
+
"heroImage",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const DECLINED: NotificationElementType[] = [
|
|
75
|
+
"heroImage",
|
|
76
|
+
"chatUnit",
|
|
77
|
+
"divider",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
describe("smsRenderer", () => {
|
|
81
|
+
it("declares a stable channel id", () => {
|
|
82
|
+
expect(smsRenderer.id).toBe("sms");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("answers supports() for every element type — the predicate is total", () => {
|
|
86
|
+
for (const type of ALL_TYPES) {
|
|
87
|
+
expect(typeof smsRenderer.supports(type), type).toBe("boolean");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("declines exactly the elements it has no surface for", () => {
|
|
92
|
+
for (const type of ALL_TYPES) {
|
|
93
|
+
expect(smsRenderer.supports(type), type).toBe(!DECLINED.includes(type));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders a content carrying every element type without throwing", () => {
|
|
98
|
+
expect(() => smsRenderer.render(CONTENT, CONTEXT)).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("omits declined elements rather than approximating them", () => {
|
|
102
|
+
const rendered = smsRenderer.render(CONTENT, CONTEXT);
|
|
103
|
+
|
|
104
|
+
// The whole string, because the claim is about what is ABSENT: no alt text
|
|
105
|
+
// standing in for the hero image, no flattened chat turn, no "---" rule.
|
|
106
|
+
expect(rendered).toBe(
|
|
107
|
+
[
|
|
108
|
+
"Hi Ada,",
|
|
109
|
+
"Acme uses Company Semantics.",
|
|
110
|
+
"Org: Acme",
|
|
111
|
+
"- Read",
|
|
112
|
+
"- Write",
|
|
113
|
+
"Security notice.",
|
|
114
|
+
"Request details:",
|
|
115
|
+
"IP: 203.0.113.7",
|
|
116
|
+
"Not expecting this? No action needed.",
|
|
117
|
+
"JOIN: https://example.test/j",
|
|
118
|
+
"- Company Semantics",
|
|
119
|
+
].join("\n"),
|
|
120
|
+
);
|
|
121
|
+
expect(rendered).not.toContain("Acme uses Company Semantics.\n---");
|
|
122
|
+
expect(rendered).not.toContain("Hello?");
|
|
123
|
+
expect(rendered).not.toContain("example.test/h.png");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns its natural type — a string, not a record", () => {
|
|
127
|
+
expect(typeof smsRenderer.render(CONTENT, CONTEXT)).toBe("string");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reads the brand from context, never from a clock or a constant", () => {
|
|
131
|
+
// No fake timer in this file, and no COMPANY_NAME import: swapping the
|
|
132
|
+
// context is enough to move the sign-off.
|
|
133
|
+
const pinned: RenderContext = {
|
|
134
|
+
brand: { name: "Acme", copyrightYear: 1999 },
|
|
135
|
+
};
|
|
136
|
+
expect(smsRenderer.render(CONTENT, pinned)).toContain("- Acme");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("lets an element's signer override the context brand", () => {
|
|
140
|
+
const onBehalf: NotificationContent = {
|
|
141
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
142
|
+
sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
|
|
143
|
+
};
|
|
144
|
+
expect(smsRenderer.render(onBehalf, CONTEXT)).toBe("- Grace");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("renders a call to action without an href as a bare label", () => {
|
|
148
|
+
// `href` absent means the label IS the payload (an OTP code). A channel must
|
|
149
|
+
// not fabricate a destination for it.
|
|
150
|
+
const otp: NotificationContent = {
|
|
151
|
+
metadata: { kind: "auth.otp", title: "Your code" },
|
|
152
|
+
sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
|
|
153
|
+
};
|
|
154
|
+
expect(smsRenderer.render(otp, CONTEXT)).toBe("123456");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("is pure — same inputs, same bytes", () => {
|
|
158
|
+
expect(smsRenderer.render(CONTENT, CONTEXT)).toBe(
|
|
159
|
+
smsRenderer.render(CONTENT, CONTEXT),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SMS channel (ADR-CONTRACTS-086) — a NON-FUNCTIONAL placeholder.
|
|
3
|
+
*
|
|
4
|
+
* This renderer exists to prove a claim `renderers/README.md` makes: a channel is
|
|
5
|
+
* added by writing a directory here, touching no element, no kind and no
|
|
6
|
+
* definition. Email could satisfy `Renderer` by construction — the seam was drawn
|
|
7
|
+
* around it. SMS could not: it is the poorest channel this vocabulary will ever
|
|
8
|
+
* meet, so if the content model survives being projected onto a plain string with
|
|
9
|
+
* no markup, no images and no layout, the model is channel-agnostic rather than
|
|
10
|
+
* email-shaped with a `Renderer` label on it.
|
|
11
|
+
*
|
|
12
|
+
* It is a placeholder in two specific senses, and neither is a TODO to be closed
|
|
13
|
+
* by tightening this file:
|
|
14
|
+
*
|
|
15
|
+
* - **It does not send.** A renderer renders. No delivery, no segmentation into
|
|
16
|
+
* 160-character parts, no phone number — those are the backend's, and the
|
|
17
|
+
* moment they appear here the seam has leaked.
|
|
18
|
+
* - **It is low fidelity.** The wording below is invented, not relocated: unlike
|
|
19
|
+
* `../email`, there are no bytes to reproduce and no golden to answer to. The
|
|
20
|
+
* first kind actually sent over SMS should expect to author its copy properly
|
|
21
|
+
* rather than trust these strings.
|
|
22
|
+
*
|
|
23
|
+
* `Out` is `string`, not a record. That is the second seam property, stated by a
|
|
24
|
+
* real module rather than a test double: email returns `{ subject, text, html }`
|
|
25
|
+
* and an SMS is one string, and neither is coerced toward the other.
|
|
26
|
+
*
|
|
27
|
+
* INVARIANTS:
|
|
28
|
+
* - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
|
|
29
|
+
* The brand comes from `context.brand`.
|
|
30
|
+
* - `supports` is total, and agrees with `render`: the three element types it
|
|
31
|
+
* declines are omitted, never approximated and never thrown on.
|
|
32
|
+
* - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
33
|
+
* lost the model — the fix for a missing fact is upstream in `compose`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { NotificationContent, NotificationElement } from "../../content";
|
|
37
|
+
import type { RenderContext } from "../../context";
|
|
38
|
+
import type { Renderer } from "../../renderer";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One element as one run of plain text, or `""` for nothing to say.
|
|
42
|
+
*
|
|
43
|
+
* The switch is total over all twelve element types rather than over the nine
|
|
44
|
+
* `supports` admits: the declined arms are unreachable — `render` filters on
|
|
45
|
+
* `supports` before calling this — but writing them out is what makes the
|
|
46
|
+
* compiler prove the two lists agree. A `default` arm would silently accept a
|
|
47
|
+
* thirteenth member of the union, which is precisely the drift this shape
|
|
48
|
+
* prevents.
|
|
49
|
+
*/
|
|
50
|
+
function renderElement(
|
|
51
|
+
element: NotificationElement,
|
|
52
|
+
context: RenderContext,
|
|
53
|
+
): string {
|
|
54
|
+
switch (element.type) {
|
|
55
|
+
case "greeting":
|
|
56
|
+
return `Hi ${element.recipientName ?? "there"},`;
|
|
57
|
+
case "body":
|
|
58
|
+
return element.text;
|
|
59
|
+
case "keyValueTable":
|
|
60
|
+
// The `:` is presentation and lives in no element (see ../../kinds/README.md).
|
|
61
|
+
return element.rows.map((row) => `${row.label}: ${row.value}`).join("\n");
|
|
62
|
+
case "callToAction":
|
|
63
|
+
// `href` absent means the label IS the payload (an OTP code). Rendering it
|
|
64
|
+
// as a bare label is the only honest option — a channel must not fabricate
|
|
65
|
+
// a destination for it.
|
|
66
|
+
return element.href ? `${element.label}: ${element.href}` : element.label;
|
|
67
|
+
case "list":
|
|
68
|
+
return element.items
|
|
69
|
+
.map((item, index) =>
|
|
70
|
+
element.ordered ? `${index + 1}. ${item}` : `- ${item}`,
|
|
71
|
+
)
|
|
72
|
+
.join("\n");
|
|
73
|
+
case "warning":
|
|
74
|
+
// Fieldless by design: the banner IS the content, and its wording belongs
|
|
75
|
+
// to the channel. A text message has no banner, so it has a sentence.
|
|
76
|
+
return "Security notice.";
|
|
77
|
+
case "metadata":
|
|
78
|
+
return [
|
|
79
|
+
element.label ? `${element.label}:` : "",
|
|
80
|
+
...element.rows.map((row) => `${row.label}: ${row.value}`),
|
|
81
|
+
]
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join("\n");
|
|
84
|
+
case "notice":
|
|
85
|
+
// Joined with a space, not a newline: the lines are one closing remark, and
|
|
86
|
+
// an SMS has no vertical budget to spend separating them.
|
|
87
|
+
return element.lines.join(" ");
|
|
88
|
+
case "signature":
|
|
89
|
+
// The brand comes from the context — never a constant here — but the year
|
|
90
|
+
// does not appear: a copyright line is a footer device and an SMS has no
|
|
91
|
+
// footer. Dropping it is a fidelity choice, not a purity hole.
|
|
92
|
+
return `- ${element.signer ?? context.brand.name}`;
|
|
93
|
+
case "divider":
|
|
94
|
+
case "heroImage":
|
|
95
|
+
case "chatUnit":
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* SMS. Sections are one message — the seam SMS declines to split on, for now:
|
|
102
|
+
* splitting one notification into several texts is a delivery decision, and
|
|
103
|
+
* delivery is not this package's.
|
|
104
|
+
*/
|
|
105
|
+
export const smsRenderer: Renderer<string> = {
|
|
106
|
+
id: "sms",
|
|
107
|
+
/**
|
|
108
|
+
* The universal subset — what survives on the poorest channel.
|
|
109
|
+
*
|
|
110
|
+
* Three declines, each a fact about the channel rather than an edit of the
|
|
111
|
+
* notification:
|
|
112
|
+
* - `heroImage` — there is no image surface. Substituting `alt` would be
|
|
113
|
+
* approximating an element, which `supports` answering `false` forbids.
|
|
114
|
+
* - `chatUnit` — the turn-taking IS the meaning, and a flat run of text cannot
|
|
115
|
+
* attribute a turn. The content model says decline the whole element rather
|
|
116
|
+
* than flatten it.
|
|
117
|
+
* - `divider` — a thematic break is a visual device and carries no content by
|
|
118
|
+
* design. SMS has no visual vocabulary to draw it with.
|
|
119
|
+
*/
|
|
120
|
+
supports: (elementType) =>
|
|
121
|
+
elementType !== "heroImage" &&
|
|
122
|
+
elementType !== "chatUnit" &&
|
|
123
|
+
elementType !== "divider",
|
|
124
|
+
render: (content: NotificationContent, context: RenderContext) =>
|
|
125
|
+
content.sections
|
|
126
|
+
.flatMap((section) => section.elements)
|
|
127
|
+
.filter((element) => smsRenderer.supports(element.type))
|
|
128
|
+
.map((element) => renderElement(element, context))
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.join("\n"),
|
|
131
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stock wording and the value formats every kind's copy is built from
|
|
3
|
+
* (ADR-CONTRACTS-086).
|
|
4
|
+
*
|
|
5
|
+
* `compose` needs a few things that are neither the content MODEL nor any
|
|
6
|
+
* channel's markup: a reassurance line every kind ends on, the phrasing for an
|
|
7
|
+
* access level, and the two display formats the kinds agree on. They lived in
|
|
8
|
+
* the email layer's `blocks.ts` alongside the markup because that was the only
|
|
9
|
+
* layer there was. They are here because what a notification SAYS is the same
|
|
10
|
+
* on every channel — an SMS reassures in the same words email does.
|
|
11
|
+
*
|
|
12
|
+
* INVARIANTS:
|
|
13
|
+
* - Channel-agnostic. Nothing here may emit markup. The moment one of these
|
|
14
|
+
* returns a tag, the seam ADR-CONTRACTS-086 draws is back where it started.
|
|
15
|
+
* - Pure. `formatExpiry` builds a `Date` from its ARGUMENT, which is not a clock
|
|
16
|
+
* read; nothing here may add one.
|
|
17
|
+
* - These strings are output. `__tests__/render-snapshot.test.ts` locks them, so
|
|
18
|
+
* a wording change here changes a real sent email.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The standard "no action required" reassurance line. */
|
|
22
|
+
export const NOTICE = "If you weren't expecting this, no action is required.";
|
|
23
|
+
|
|
24
|
+
/** Human phrasing per access level (share-granted, access-approved). */
|
|
25
|
+
export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
|
|
26
|
+
{
|
|
27
|
+
editor: "can edit",
|
|
28
|
+
commenter: "can comment",
|
|
29
|
+
viewer: "can view",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Title-case a display value — capitalize the first letter of each word, e.g. a
|
|
33
|
+
* role like "admin" → "Admin". Leaves already-capitalized letters untouched. */
|
|
34
|
+
export function titleCase(s: string): string {
|
|
35
|
+
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format an ISO timestamp as "Jun 13, 2026". Pinned to en-US + UTC so the
|
|
40
|
+
* output is locale-/timezone-independent and rendered bytes stay deterministic.
|
|
41
|
+
* Returns the raw input unchanged if it is not a parseable date.
|
|
42
|
+
*/
|
|
43
|
+
export function formatExpiry(iso: string): string {
|
|
44
|
+
const d = new Date(iso);
|
|
45
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
46
|
+
return d.toLocaleDateString("en-US", {
|
|
47
|
+
month: "short",
|
|
48
|
+
day: "numeric",
|
|
49
|
+
year: "numeric",
|
|
50
|
+
timeZone: "UTC",
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/email/README.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# email/
|
|
2
|
-
|
|
3
|
-
## Purpose
|
|
4
|
-
|
|
5
|
-
Transactional email vocabulary **and** render layer: kind types, the subject
|
|
6
|
-
registry, and the shared component system (`render/`) that both the backend
|
|
7
|
-
(real sends) and the app (Ladle preview) compose. See ADR-CONTRACTS-077.
|
|
8
|
-
|
|
9
|
-
## Invariants
|
|
10
|
-
|
|
11
|
-
- Types + **pure** render functions only — no classes, no side effects, no
|
|
12
|
-
external imports beyond `zod` (vocabulary-guard enforced).
|
|
13
|
-
- `render/blocks.ts` owns all styled markup; templates supply only content text.
|
|
14
|
-
Editing a block restyles every email — HTML and plain text — in both repos.
|
|
15
|
-
- `renderEmail(kind, payload, options?)` is the single dispatcher; subject comes
|
|
16
|
-
from `EMAIL_KINDS`, never hardcoded in a template.
|
|
17
|
-
- `EmailKind` union MUST match keys in `EMAIL_KINDS` registry.
|
|
18
|
-
- OTP values MUST NEVER appear as literals in contracts (security: logged if
|
|
19
|
-
leaked) — they are runtime payload fields only.
|
|
20
|
-
- Every user-controlled field in an HTML variant MUST pass through `escapeHtml`.
|
|
21
|
-
- Rendered output is locked by `render/__tests__/render-snapshot.test.ts`
|
|
22
|
-
(production-parity markup — a snapshot change means a real email changed).
|
|
23
|
-
- Unknown email kinds MUST be rejected at API boundaries via `isValidEmailKind()`.
|
|
24
|
-
|
|
25
|
-
<!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
|
|
26
|
-
|
|
27
|
-
## Public API
|
|
28
|
-
|
|
29
|
-
- `EMAIL_KINDS` — EMAILKINDS is the authoritative registry.
|
|
30
|
-
- `EmailKind` _(type)_ — EmailKind identifies the type of transactional email.
|
|
31
|
-
- `EmailKindDefinition` _(type)_ — Complete definition for an email kind.
|
|
32
|
-
- `EmailPayloads` _(type)_ — Type-safe payload mapping for each email kind.
|
|
33
|
-
- `SendEmailInput` _(type)_ — Type-safe input for sending emails.
|
|
34
|
-
- `getEmailKindDefinition` — Type-safe registry lookup.
|
|
35
|
-
- `isValidEmailKind` — Check if a string is a valid EmailKind.
|
|
36
|
-
|
|
37
|
-
<!-- END GENERATED: readme-public-api -->
|
|
38
|
-
|
|
39
|
-
<!-- BEGIN GENERATED: readme-dependencies — derived from code by `pnpm readme-api`. Do not edit. -->
|
|
40
|
-
|
|
41
|
-
## Dependencies
|
|
42
|
-
|
|
43
|
-
**Internal domains:**
|
|
44
|
-
|
|
45
|
-
_None._
|
|
46
|
-
|
|
47
|
-
**External packages:**
|
|
48
|
-
|
|
49
|
-
_None._
|
|
50
|
-
|
|
51
|
-
<!-- END GENERATED: readme-dependencies -->
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
EMAIL_KINDS,
|
|
4
|
-
getEmailKindDefinition,
|
|
5
|
-
isValidEmailKind,
|
|
6
|
-
} from "../registry.js";
|
|
7
|
-
|
|
8
|
-
describe("EMAIL_KINDS golden snapshot", () => {
|
|
9
|
-
it("exact values are frozen", () => {
|
|
10
|
-
expect(EMAIL_KINDS).toStrictEqual({
|
|
11
|
-
"auth.otp": {
|
|
12
|
-
kind: "auth.otp",
|
|
13
|
-
subject: "Your login code for Company Semantics",
|
|
14
|
-
plainTextRequired: true,
|
|
15
|
-
htmlSupported: false,
|
|
16
|
-
},
|
|
17
|
-
"auth.magic_link": {
|
|
18
|
-
kind: "auth.magic_link",
|
|
19
|
-
subject: "Your login link",
|
|
20
|
-
plainTextRequired: true,
|
|
21
|
-
htmlSupported: false,
|
|
22
|
-
},
|
|
23
|
-
"org.invite": {
|
|
24
|
-
kind: "org.invite",
|
|
25
|
-
subject: "You've been invited to join {orgName} on Company Semantics",
|
|
26
|
-
plainTextRequired: true,
|
|
27
|
-
htmlSupported: true,
|
|
28
|
-
},
|
|
29
|
-
"org.unit_owner_granted": {
|
|
30
|
-
kind: "org.unit_owner_granted",
|
|
31
|
-
subject: "You've been added as {roleWord} to {unitName} in {orgName}",
|
|
32
|
-
plainTextRequired: true,
|
|
33
|
-
htmlSupported: true,
|
|
34
|
-
},
|
|
35
|
-
"org.ownership_transfer": {
|
|
36
|
-
kind: "org.ownership_transfer",
|
|
37
|
-
subject: "You've been invited to become a workspace owner",
|
|
38
|
-
plainTextRequired: true,
|
|
39
|
-
htmlSupported: false,
|
|
40
|
-
},
|
|
41
|
-
"org.ownership_transfer_completed": {
|
|
42
|
-
kind: "org.ownership_transfer_completed",
|
|
43
|
-
subject: "Workspace ownership has been transferred",
|
|
44
|
-
plainTextRequired: true,
|
|
45
|
-
htmlSupported: false,
|
|
46
|
-
},
|
|
47
|
-
"security.alert": {
|
|
48
|
-
kind: "security.alert",
|
|
49
|
-
subject: "Security alert for your account",
|
|
50
|
-
plainTextRequired: true,
|
|
51
|
-
htmlSupported: false,
|
|
52
|
-
},
|
|
53
|
-
"chat.shared": {
|
|
54
|
-
kind: "chat.shared",
|
|
55
|
-
subject: "A chat has been shared with you",
|
|
56
|
-
plainTextRequired: true,
|
|
57
|
-
htmlSupported: true,
|
|
58
|
-
},
|
|
59
|
-
"share.granted": {
|
|
60
|
-
kind: "share.granted",
|
|
61
|
-
subject: "Something has been shared with you",
|
|
62
|
-
plainTextRequired: true,
|
|
63
|
-
htmlSupported: true,
|
|
64
|
-
},
|
|
65
|
-
"companyMd.access_requested": {
|
|
66
|
-
kind: "companyMd.access_requested",
|
|
67
|
-
subject: "Someone requested access to a document",
|
|
68
|
-
plainTextRequired: true,
|
|
69
|
-
htmlSupported: true,
|
|
70
|
-
},
|
|
71
|
-
"companyMd.access_request_approved": {
|
|
72
|
-
kind: "companyMd.access_request_approved",
|
|
73
|
-
subject: "Your access request was approved",
|
|
74
|
-
plainTextRequired: true,
|
|
75
|
-
htmlSupported: true,
|
|
76
|
-
},
|
|
77
|
-
"companyMd.access_request_denied": {
|
|
78
|
-
kind: "companyMd.access_request_denied",
|
|
79
|
-
subject: "Your access request was reviewed",
|
|
80
|
-
plainTextRequired: true,
|
|
81
|
-
htmlSupported: true,
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe("EMAIL_KINDS registry invariants", () => {
|
|
88
|
-
it("every registry key matches its definition.kind field", () => {
|
|
89
|
-
for (const [key, def] of Object.entries(EMAIL_KINDS)) {
|
|
90
|
-
expect(def.kind).toBe(key);
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("all subjects are non-empty strings", () => {
|
|
95
|
-
for (const def of Object.values(EMAIL_KINDS)) {
|
|
96
|
-
expect(typeof def.subject).toBe("string");
|
|
97
|
-
expect(def.subject.length).toBeGreaterThan(0);
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("all entries have required boolean fields plainTextRequired and htmlSupported", () => {
|
|
102
|
-
for (const def of Object.values(EMAIL_KINDS)) {
|
|
103
|
-
expect(typeof def.plainTextRequired).toBe("boolean");
|
|
104
|
-
expect(typeof def.htmlSupported).toBe("boolean");
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("getEmailKindDefinition", () => {
|
|
110
|
-
it("returns correct definition for auth.otp", () => {
|
|
111
|
-
const def = getEmailKindDefinition("auth.otp");
|
|
112
|
-
expect(def).toStrictEqual(EMAIL_KINDS["auth.otp"]);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("returns correct definition for auth.magic_link", () => {
|
|
116
|
-
const def = getEmailKindDefinition("auth.magic_link");
|
|
117
|
-
expect(def).toStrictEqual(EMAIL_KINDS["auth.magic_link"]);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("returns correct definition for org.invite", () => {
|
|
121
|
-
const def = getEmailKindDefinition("org.invite");
|
|
122
|
-
expect(def).toStrictEqual(EMAIL_KINDS["org.invite"]);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("returns correct definition for security.alert", () => {
|
|
126
|
-
const def = getEmailKindDefinition("security.alert");
|
|
127
|
-
expect(def).toStrictEqual(EMAIL_KINDS["security.alert"]);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("returns correct definition for chat.shared", () => {
|
|
131
|
-
const def = getEmailKindDefinition("chat.shared");
|
|
132
|
-
expect(def).toStrictEqual(EMAIL_KINDS["chat.shared"]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("return value matches registry entry exactly", () => {
|
|
136
|
-
for (const [key, expected] of Object.entries(EMAIL_KINDS)) {
|
|
137
|
-
const def = getEmailKindDefinition(key as keyof typeof EMAIL_KINDS);
|
|
138
|
-
expect(def).toBe(expected);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe("isValidEmailKind", () => {
|
|
144
|
-
it("returns true for all valid email kinds", () => {
|
|
145
|
-
for (const kind of Object.keys(EMAIL_KINDS)) {
|
|
146
|
-
expect(isValidEmailKind(kind)).toBe(true);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("returns false for unknown string", () => {
|
|
151
|
-
expect(isValidEmailKind("unknown.kind")).toBe(false);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("returns false for empty string", () => {
|
|
155
|
-
expect(isValidEmailKind("")).toBe(false);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("returns false for partial match", () => {
|
|
159
|
-
expect(isValidEmailKind("auth")).toBe(false);
|
|
160
|
-
});
|
|
161
|
-
});
|
package/src/email/index.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Email Domain Barrel
|
|
3
|
-
*
|
|
4
|
-
* Re-exports email kind vocabulary types and registry.
|
|
5
|
-
* Import from '@company-semantics/contracts/email'.
|
|
6
|
-
*
|
|
7
|
-
* @see ADR-CONT-034 for design rationale
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
// =============================================================================
|
|
11
|
-
// Kind Types
|
|
12
|
-
// =============================================================================
|
|
13
|
-
|
|
14
|
-
export type { EmailKind, EmailPayloads, SendEmailInput } from "./types";
|
|
15
|
-
|
|
16
|
-
// =============================================================================
|
|
17
|
-
// Definition Types
|
|
18
|
-
// =============================================================================
|
|
19
|
-
|
|
20
|
-
export type { EmailKindDefinition } from "./registry";
|
|
21
|
-
|
|
22
|
-
// =============================================================================
|
|
23
|
-
// Registry
|
|
24
|
-
// =============================================================================
|
|
25
|
-
|
|
26
|
-
export {
|
|
27
|
-
EMAIL_KINDS,
|
|
28
|
-
getEmailKindDefinition,
|
|
29
|
-
isValidEmailKind,
|
|
30
|
-
} from "./registry";
|
|
31
|
-
|
|
32
|
-
// =============================================================================
|
|
33
|
-
// Render Layer
|
|
34
|
-
// =============================================================================
|
|
35
|
-
|
|
36
|
-
export * from "./render";
|