@company-semantics/contracts 45.2.0 → 45.3.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 +9 -2
- package/src/__tests__/resource-keys.test.ts +59 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +93 -2
- package/src/comments/README.md +115 -0
- package/src/comments/__tests__/README.md +49 -0
- package/src/comments/__tests__/anchor-corpus.test.ts +202 -0
- package/src/comments/__tests__/fixtures/README.md +58 -0
- package/src/comments/__tests__/fixtures/comment-anchors.json +94 -0
- package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +9 -0
- package/src/comments/__tests__/schemas.test.ts +274 -0
- package/src/comments/anchor.ts +120 -0
- package/src/comments/index.ts +43 -0
- package/src/comments/schemas.ts +227 -0
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +42 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +4 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +741 -0
- package/src/notifications/__tests__/definition.test.ts +4 -3
- package/src/notifications/__tests__/fixtures.ts +34 -0
- package/src/notifications/__tests__/kinds.test.ts +11 -4
- package/src/notifications/__tests__/registry.test.ts +7 -5
- package/src/notifications/__tests__/render-snapshot.test.ts +36 -0
- package/src/notifications/kinds/comment-mention.ts +82 -0
- package/src/notifications/kinds/comment-reply.ts +79 -0
- package/src/notifications/kinds/index.ts +2 -0
- package/src/notifications/kinds.ts +12 -3
- package/src/notifications/payloads.ts +49 -0
- package/src/notifications/registry.ts +6 -2
- package/src/resource-keys.ts +68 -0
- package/src/user-notifications/kinds.ts +13 -1
|
@@ -110,10 +110,11 @@ describe("compose reads ambient state from the context", () => {
|
|
|
110
110
|
brand: { name: "Umbrella Corp", copyrightYear: 2026 },
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
//
|
|
114
|
-
// notice at all, on purpose.
|
|
113
|
+
// Every kind but ONE footers "sent via <brand>"; security.alert has no
|
|
114
|
+
// notice at all, on purpose. The literal count is the tripwire: a kind
|
|
115
|
+
// added without a branded notice lands here rather than in production.
|
|
115
116
|
const branded = KINDS.filter((kind) => kind !== "security.alert");
|
|
116
|
-
expect(branded).toHaveLength(
|
|
117
|
+
expect(branded).toHaveLength(12);
|
|
117
118
|
|
|
118
119
|
for (const kind of branded) {
|
|
119
120
|
const notices = elementsOf(compose(kind, context)).filter(
|
|
@@ -238,5 +238,39 @@ add("companyMd.access_request_denied", "No reason", {
|
|
|
238
238
|
docTitle: "Engineering Handbook",
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
// Both comment kinds vary on the SAME axis — whether the thread is anchored to
|
|
242
|
+
// a passage — because that is the only branch either `compose` has.
|
|
243
|
+
add("comment.mention", "Text-anchored", {
|
|
244
|
+
actorName: "Jordan Lee",
|
|
245
|
+
recipientName: "Sam Chen",
|
|
246
|
+
subjectTitle: "Engineering Handbook",
|
|
247
|
+
quotedText: "ship the quarterly goals",
|
|
248
|
+
commentExcerpt:
|
|
249
|
+
"Should this say Q3 or Q4? @Sam Chen you owned this last time.",
|
|
250
|
+
threadUrl: `${APP}/doc/handbook?thread=th_123`,
|
|
251
|
+
});
|
|
252
|
+
add("comment.mention", "Document-level", {
|
|
253
|
+
actorName: "Jordan Lee",
|
|
254
|
+
subjectTitle: "Engineering Handbook",
|
|
255
|
+
commentExcerpt:
|
|
256
|
+
"Adding @Sam Chen — this whole doc needs a pass before Friday.",
|
|
257
|
+
threadUrl: `${APP}/doc/handbook?thread=th_456`,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
add("comment.reply", "Text-anchored", {
|
|
261
|
+
actorName: "Alex Rivera",
|
|
262
|
+
recipientName: "Sam Chen",
|
|
263
|
+
subjectTitle: "Engineering Handbook",
|
|
264
|
+
quotedText: "ship the quarterly goals",
|
|
265
|
+
commentExcerpt: "Q4 — we moved it at the last planning session.",
|
|
266
|
+
threadUrl: `${APP}/doc/handbook?thread=th_123`,
|
|
267
|
+
});
|
|
268
|
+
add("comment.reply", "Document-level", {
|
|
269
|
+
actorName: "Alex Rivera",
|
|
270
|
+
subjectTitle: "Engineering Handbook",
|
|
271
|
+
commentExcerpt: "Started on the onboarding section.",
|
|
272
|
+
threadUrl: `${APP}/doc/handbook?thread=th_456`,
|
|
273
|
+
});
|
|
274
|
+
|
|
241
275
|
/** The corpus — every implemented kind × representative variants. */
|
|
242
276
|
export const NOTIFICATION_FIXTURES: readonly NotificationFixture[] = fixtures;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `NotificationKind` is the migration's central claim: that the
|
|
3
|
-
*
|
|
4
|
-
* (ADR-CONTRACTS-085).
|
|
2
|
+
* `NotificationKind` is the migration's central claim: that the things the
|
|
3
|
+
* email layer could actually SAY are notifications, not emails
|
|
4
|
+
* (ADR-CONTRACTS-085). It has grown since — `comment.mention` and
|
|
5
|
+
* `comment.reply` joined with ADR-CONTRACTS-116 — which is the ordinary way
|
|
6
|
+
* this union changes and exactly what the frozen list below is here to make
|
|
7
|
+
* visible.
|
|
5
8
|
*
|
|
6
9
|
* WHAT THESE TESTS USED TO DO, AND WHY THEY NO LONGER DO IT. Membership was
|
|
7
10
|
* asserted against `IMPLEMENTED_EMAIL_KINDS`, and the union's deliberate
|
|
@@ -49,15 +52,19 @@ const ALL_NOTIFICATION_KINDS: Record<NotificationKind, true> = {
|
|
|
49
52
|
"companyMd.access_requested": true,
|
|
50
53
|
"companyMd.access_request_approved": true,
|
|
51
54
|
"companyMd.access_request_denied": true,
|
|
55
|
+
"comment.mention": true,
|
|
56
|
+
"comment.reply": true,
|
|
52
57
|
};
|
|
53
58
|
|
|
54
59
|
const KINDS = Object.keys(ALL_NOTIFICATION_KINDS).sort();
|
|
55
60
|
|
|
56
61
|
describe("NotificationKind", () => {
|
|
57
|
-
it("has exactly the
|
|
62
|
+
it("has exactly the kinds with something to say", () => {
|
|
58
63
|
expect(KINDS).toEqual([
|
|
59
64
|
"auth.otp",
|
|
60
65
|
"chat.shared",
|
|
66
|
+
"comment.mention",
|
|
67
|
+
"comment.reply",
|
|
61
68
|
"companyMd.access_request_approved",
|
|
62
69
|
"companyMd.access_request_denied",
|
|
63
70
|
"companyMd.access_requested",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* The new one is TOTALITY. `getEmailKindDefinition` could hand back a definition
|
|
10
10
|
* whose kind then threw at render; `getNotificationKindDefinition` cannot,
|
|
11
11
|
* because a kind with no `compose` cannot reach the union. That is the whole
|
|
12
|
-
* reason `NotificationKind`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* reason `NotificationKind` excluded a member `EmailKind` had, and it is only
|
|
13
|
+
* true as long as every definition here actually composes — which the last test
|
|
14
|
+
* checks by calling all of them rather than trusting the type.
|
|
15
15
|
*
|
|
16
16
|
* And the ABSENCES: `subject`, `plainTextRequired` and `htmlSupported` are gone.
|
|
17
17
|
* Asserting a field is missing looks pedantic until someone re-adds `subject`
|
|
@@ -51,6 +51,8 @@ describe("NOTIFICATION_DEFINITIONS", () => {
|
|
|
51
51
|
expect([...KINDS].sort()).toEqual([
|
|
52
52
|
"auth.otp",
|
|
53
53
|
"chat.shared",
|
|
54
|
+
"comment.mention",
|
|
55
|
+
"comment.reply",
|
|
54
56
|
"companyMd.access_request_approved",
|
|
55
57
|
"companyMd.access_request_denied",
|
|
56
58
|
"companyMd.access_requested",
|
|
@@ -86,7 +88,7 @@ describe("NOTIFICATION_DEFINITIONS", () => {
|
|
|
86
88
|
|
|
87
89
|
it("names no destination for any kind", () => {
|
|
88
90
|
// Capability is asked of a renderer, never declared on a definition —
|
|
89
|
-
// otherwise every new surface is a rewrite of
|
|
91
|
+
// otherwise every new surface is a rewrite of every definition.
|
|
90
92
|
for (const definition of Object.values(NOTIFICATION_DEFINITIONS)) {
|
|
91
93
|
expect(Object.keys(definition).sort()).toEqual(["compose", "kind"]);
|
|
92
94
|
}
|
|
@@ -94,7 +96,7 @@ describe("NOTIFICATION_DEFINITIONS", () => {
|
|
|
94
96
|
|
|
95
97
|
it("composes for every kind, so the registry is total in practice and not just in type", () => {
|
|
96
98
|
// The claim that makes `getNotificationKindDefinition` safe where
|
|
97
|
-
// `getEmailKindDefinition` was not. Calling
|
|
99
|
+
// `getEmailKindDefinition` was not. Calling every one is the only way to
|
|
98
100
|
// know it: a type says a `compose` exists, not that it returns content.
|
|
99
101
|
for (const kind of KINDS) {
|
|
100
102
|
const compose = NOTIFICATION_DEFINITIONS[kind].compose as (
|
|
@@ -238,6 +238,42 @@ add("companyMd.access_request_denied", "No reason", {
|
|
|
238
238
|
docTitle: "Engineering Handbook",
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
// The anchored/unanchored branch, across every channel: the `Passage` row is
|
|
242
|
+
// present only when the payload carries a quote, because a document-level
|
|
243
|
+
// thread is anchored to nothing in particular and an empty row would state
|
|
244
|
+
// something false rather than omit something absent.
|
|
245
|
+
add("comment.mention", "Text-anchored", {
|
|
246
|
+
actorName: "Jordan Lee",
|
|
247
|
+
recipientName: "Sam Chen",
|
|
248
|
+
subjectTitle: "Engineering Handbook",
|
|
249
|
+
quotedText: "ship the quarterly goals",
|
|
250
|
+
commentExcerpt:
|
|
251
|
+
"Should this say Q3 or Q4? @Sam Chen you owned this last time.",
|
|
252
|
+
threadUrl: `${APP}/doc/handbook?thread=th_123`,
|
|
253
|
+
});
|
|
254
|
+
add("comment.mention", "Document-level", {
|
|
255
|
+
actorName: "Jordan Lee",
|
|
256
|
+
subjectTitle: "Engineering Handbook",
|
|
257
|
+
commentExcerpt:
|
|
258
|
+
"Adding @Sam Chen — this whole doc needs a pass before Friday.",
|
|
259
|
+
threadUrl: `${APP}/doc/handbook?thread=th_456`,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
add("comment.reply", "Text-anchored", {
|
|
263
|
+
actorName: "Alex Rivera",
|
|
264
|
+
recipientName: "Sam Chen",
|
|
265
|
+
subjectTitle: "Engineering Handbook",
|
|
266
|
+
quotedText: "ship the quarterly goals",
|
|
267
|
+
commentExcerpt: "Q4 — we moved it at the last planning session.",
|
|
268
|
+
threadUrl: `${APP}/doc/handbook?thread=th_123`,
|
|
269
|
+
});
|
|
270
|
+
add("comment.reply", "Document-level", {
|
|
271
|
+
actorName: "Alex Rivera",
|
|
272
|
+
subjectTitle: "Engineering Handbook",
|
|
273
|
+
commentExcerpt: "Started on the onboarding section.",
|
|
274
|
+
threadUrl: `${APP}/doc/handbook?thread=th_456`,
|
|
275
|
+
});
|
|
276
|
+
|
|
241
277
|
// "email render", not "notification render": the snapshot keys are
|
|
242
278
|
// `email render > {kind} · {name} 1`, and renaming the describe would orphan
|
|
243
279
|
// every one of them — regenerating the whole .snap to prove nothing changed is
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment-mention notification — sent to someone named in a comment
|
|
3
|
+
* (ADR-CONTRACTS-116).
|
|
4
|
+
*
|
|
5
|
+
* Leads with WHO named you and WHERE, because that is what decides whether the
|
|
6
|
+
* recipient opens it; the comment text follows as a reproduced turn rather than
|
|
7
|
+
* a body paragraph, so a channel that can depict a conversation depicts one.
|
|
8
|
+
*
|
|
9
|
+
* The anchored passage is shown as a key-value row and only when the payload
|
|
10
|
+
* carries it: a document-level thread is anchored to nothing in particular, and
|
|
11
|
+
* a "Passage: —" row would state something false rather than omit something
|
|
12
|
+
* absent.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { NotificationDefinition } from "../definition";
|
|
16
|
+
|
|
17
|
+
export const commentMentionDefinition: NotificationDefinition<"comment.mention"> =
|
|
18
|
+
{
|
|
19
|
+
kind: "comment.mention",
|
|
20
|
+
compose: (payload, context) => {
|
|
21
|
+
const {
|
|
22
|
+
actorName,
|
|
23
|
+
recipientName,
|
|
24
|
+
subjectTitle,
|
|
25
|
+
quotedText,
|
|
26
|
+
commentExcerpt,
|
|
27
|
+
threadUrl,
|
|
28
|
+
} = payload;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
metadata: {
|
|
32
|
+
kind: "comment.mention",
|
|
33
|
+
title: `${actorName} mentioned you in a comment`,
|
|
34
|
+
},
|
|
35
|
+
sections: [
|
|
36
|
+
{
|
|
37
|
+
elements: [
|
|
38
|
+
{ type: "greeting", ...(recipientName ? { recipientName } : {}) },
|
|
39
|
+
{
|
|
40
|
+
type: "body",
|
|
41
|
+
text: `${actorName} mentioned you in a comment on "${subjectTitle}".`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: "chatUnit",
|
|
45
|
+
items: [
|
|
46
|
+
{
|
|
47
|
+
type: "message",
|
|
48
|
+
role: "user",
|
|
49
|
+
text: commentExcerpt,
|
|
50
|
+
from: actorName,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: "callToAction",
|
|
54
|
+
label: "VIEW COMMENT",
|
|
55
|
+
href: threadUrl,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: "keyValueTable",
|
|
61
|
+
rows: [
|
|
62
|
+
{ label: "From", value: actorName },
|
|
63
|
+
{ label: "On", value: `"${subjectTitle}"` },
|
|
64
|
+
...(quotedText
|
|
65
|
+
? [{ label: "Passage", value: `"${quotedText}"` }]
|
|
66
|
+
: []),
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: "notice",
|
|
71
|
+
lines: [
|
|
72
|
+
`This notification was sent via ${context.brand.name}.`,
|
|
73
|
+
"You were named in this comment.",
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{ type: "signature" },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment-reply notification — sent to the other participants of a thread when
|
|
3
|
+
* it gets a new comment (ADR-CONTRACTS-116).
|
|
4
|
+
*
|
|
5
|
+
* Deliberately a near-twin of `./comment-mention` with a DIFFERENT closing
|
|
6
|
+
* line, and the difference is the whole point of keeping two definitions: a
|
|
7
|
+
* mention says "you were named", a reply says "a thread you took part in moved
|
|
8
|
+
* on". Collapsing them into one kind with a flag would put that editorial
|
|
9
|
+
* choice inside a conditional, where the two would slowly converge.
|
|
10
|
+
*
|
|
11
|
+
* The sender guarantees a person receives at most one of the two for any one
|
|
12
|
+
* comment; nothing here needs to know that, which is why neither payload
|
|
13
|
+
* carries the other's audience.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { NotificationDefinition } from "../definition";
|
|
17
|
+
|
|
18
|
+
export const commentReplyDefinition: NotificationDefinition<"comment.reply"> = {
|
|
19
|
+
kind: "comment.reply",
|
|
20
|
+
compose: (payload, context) => {
|
|
21
|
+
const {
|
|
22
|
+
actorName,
|
|
23
|
+
recipientName,
|
|
24
|
+
subjectTitle,
|
|
25
|
+
quotedText,
|
|
26
|
+
commentExcerpt,
|
|
27
|
+
threadUrl,
|
|
28
|
+
} = payload;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
metadata: {
|
|
32
|
+
kind: "comment.reply",
|
|
33
|
+
title: `${actorName} replied in a thread you are part of`,
|
|
34
|
+
},
|
|
35
|
+
sections: [
|
|
36
|
+
{
|
|
37
|
+
elements: [
|
|
38
|
+
{ type: "greeting", ...(recipientName ? { recipientName } : {}) },
|
|
39
|
+
{
|
|
40
|
+
type: "body",
|
|
41
|
+
text: `${actorName} replied in a comment thread on "${subjectTitle}".`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: "chatUnit",
|
|
45
|
+
items: [
|
|
46
|
+
{ type: "continuation" },
|
|
47
|
+
{
|
|
48
|
+
type: "message",
|
|
49
|
+
role: "user",
|
|
50
|
+
text: commentExcerpt,
|
|
51
|
+
from: actorName,
|
|
52
|
+
},
|
|
53
|
+
{ type: "callToAction", label: "VIEW THREAD", href: threadUrl },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "keyValueTable",
|
|
58
|
+
rows: [
|
|
59
|
+
{ label: "From", value: actorName },
|
|
60
|
+
{ label: "On", value: `"${subjectTitle}"` },
|
|
61
|
+
...(quotedText
|
|
62
|
+
? [{ label: "Passage", value: `"${quotedText}"` }]
|
|
63
|
+
: []),
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: "notice",
|
|
68
|
+
lines: [
|
|
69
|
+
`This notification was sent via ${context.brand.name}.`,
|
|
70
|
+
"You are part of this thread.",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{ type: "signature" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -11,6 +11,8 @@ export { accessDeniedDefinition } from "./company-md-access-denied";
|
|
|
11
11
|
export { accessRequestedDefinition } from "./company-md-access-requested";
|
|
12
12
|
export { authOtpDefinition } from "./auth-otp";
|
|
13
13
|
export { chatSharedDefinition } from "./chat-shared";
|
|
14
|
+
export { commentMentionDefinition } from "./comment-mention";
|
|
15
|
+
export { commentReplyDefinition } from "./comment-reply";
|
|
14
16
|
export { orgInviteDefinition } from "./org-invite";
|
|
15
17
|
export { ownershipTransferCompletedDefinition } from "./ownership-transfer-completed";
|
|
16
18
|
export { ownershipTransferDefinition } from "./ownership-transfer";
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
* - domain: auth, org, security, chat, share, companyMd
|
|
34
34
|
* - type: specific notification variant
|
|
35
35
|
*
|
|
36
|
-
* This union is exactly the kinds with a `compose` implementation —
|
|
37
|
-
* email layer could render, and deliberately NOT the 12 its
|
|
36
|
+
* This union is exactly the kinds with a `compose` implementation — it started
|
|
37
|
+
* as the 11 the email layer could render, and deliberately NOT the 12 its
|
|
38
|
+
* `EmailKind` had.
|
|
38
39
|
* `auth.magic_link` was registered with a subject but never had a template, so
|
|
39
40
|
* the old `renderEmail("auth.magic_link", …)` type-checked and then threw at
|
|
40
41
|
* runtime. Excluding it closes that hole by construction: a kind IS its
|
|
@@ -65,4 +66,12 @@ export type NotificationKind =
|
|
|
65
66
|
| "share.granted"
|
|
66
67
|
| "companyMd.access_requested"
|
|
67
68
|
| "companyMd.access_request_approved"
|
|
68
|
-
| "companyMd.access_request_denied"
|
|
69
|
+
| "companyMd.access_request_denied"
|
|
70
|
+
// Comment activity (ADR-CONTRACTS-116). These two names also exist in
|
|
71
|
+
// `../user-notifications`'s `UserNotificationKind`, and the overlap is
|
|
72
|
+
// deliberate and already precedented by the three `companyMd.*` members
|
|
73
|
+
// above: the message that LEAVES THE BUILDING and the durable row you come
|
|
74
|
+
// back to are two different things about one event, with two lifecycles.
|
|
75
|
+
// Neither union is derived from the other.
|
|
76
|
+
| "comment.mention"
|
|
77
|
+
| "comment.reply";
|
|
@@ -158,6 +158,55 @@ export interface NotificationPayloads {
|
|
|
158
158
|
*/
|
|
159
159
|
message?: string;
|
|
160
160
|
};
|
|
161
|
+
/**
|
|
162
|
+
* Sent to someone named in a comment (ADR-CONTRACTS-116).
|
|
163
|
+
*
|
|
164
|
+
* FACTS ONLY, AND DELIBERATELY NOT THE ANCHOR. A notification says who,
|
|
165
|
+
* where and what was written; it does not carry the machinery for placing a
|
|
166
|
+
* highlight, because a composed message has no document to place it in. The
|
|
167
|
+
* anchor's `quote` is passed as ordinary prose (`quotedText`) precisely so
|
|
168
|
+
* this layer cannot grow an opinion about anchors.
|
|
169
|
+
*/
|
|
170
|
+
"comment.mention": {
|
|
171
|
+
/** Display name of the person who wrote the comment naming the recipient. */
|
|
172
|
+
actorName: string;
|
|
173
|
+
/** Display name of the recipient (optional; falls back to a neutral greeting) */
|
|
174
|
+
recipientName?: string;
|
|
175
|
+
/** Human-readable label for the thing commented on, e.g. "Engineering Handbook". */
|
|
176
|
+
subjectTitle: string;
|
|
177
|
+
/**
|
|
178
|
+
* The passage the thread is anchored to, as plain text. Absent for a
|
|
179
|
+
* document-level thread, which is anchored to nothing in particular.
|
|
180
|
+
*/
|
|
181
|
+
quotedText?: string;
|
|
182
|
+
/** The comment body, already excerpted by the sender. Never the whole thread. */
|
|
183
|
+
commentExcerpt: string;
|
|
184
|
+
/** Full URL that opens the subject with this thread selected. */
|
|
185
|
+
threadUrl: string;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Sent to the other participants of a thread when it gets a new comment
|
|
189
|
+
* (ADR-CONTRACTS-116).
|
|
190
|
+
*
|
|
191
|
+
* ONE COMMENT NEVER PRODUCES TWO MESSAGES FOR ONE PERSON: a participant who
|
|
192
|
+
* was also mentioned receives `comment.mention` and not this. The sender
|
|
193
|
+
* resolves that, not `compose` — which is why the two payloads are separate
|
|
194
|
+
* shapes rather than one with a flag.
|
|
195
|
+
*/
|
|
196
|
+
"comment.reply": {
|
|
197
|
+
/** Display name of the person who replied. */
|
|
198
|
+
actorName: string;
|
|
199
|
+
/** Display name of the recipient (optional; falls back to a neutral greeting) */
|
|
200
|
+
recipientName?: string;
|
|
201
|
+
/** Human-readable label for the thing commented on, e.g. "Engineering Handbook". */
|
|
202
|
+
subjectTitle: string;
|
|
203
|
+
/** The passage the thread is anchored to. Absent for a document-level thread. */
|
|
204
|
+
quotedText?: string;
|
|
205
|
+
/** The reply body, already excerpted by the sender. */
|
|
206
|
+
commentExcerpt: string;
|
|
207
|
+
/** Full URL that opens the subject with this thread selected. */
|
|
208
|
+
threadUrl: string;
|
|
209
|
+
};
|
|
161
210
|
/** Sent to the requester when an owner denies (ADR-BE-338). */
|
|
162
211
|
"companyMd.access_request_denied": {
|
|
163
212
|
/** Display name of the owner who denied */
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
accessRequestedDefinition,
|
|
28
28
|
authOtpDefinition,
|
|
29
29
|
chatSharedDefinition,
|
|
30
|
+
commentMentionDefinition,
|
|
31
|
+
commentReplyDefinition,
|
|
30
32
|
orgInviteDefinition,
|
|
31
33
|
ownershipTransferCompletedDefinition,
|
|
32
34
|
ownershipTransferDefinition,
|
|
@@ -43,8 +45,8 @@ import {
|
|
|
43
45
|
* The registry's shape: `Record<NotificationKind, NotificationDefinition>` made
|
|
44
46
|
* per-kind precise.
|
|
45
47
|
*
|
|
46
|
-
* A plain `Record` would widen every `compose` to accept a union of
|
|
47
|
-
*
|
|
48
|
+
* A plain `Record` would widen every `compose` to accept a union of every
|
|
49
|
+
* payload, and — since `compose` is an arrow property, checked contravariantly
|
|
48
50
|
* — no real definition would be assignable to it. The mapped type keeps each
|
|
49
51
|
* kind bound to its own payload, which is what a caller wants anyway.
|
|
50
52
|
*/
|
|
@@ -75,6 +77,8 @@ export const NOTIFICATION_DEFINITIONS: NotificationDefinitions = {
|
|
|
75
77
|
"companyMd.access_requested": accessRequestedDefinition,
|
|
76
78
|
"companyMd.access_request_approved": accessApprovedDefinition,
|
|
77
79
|
"companyMd.access_request_denied": accessDeniedDefinition,
|
|
80
|
+
"comment.mention": commentMentionDefinition,
|
|
81
|
+
"comment.reply": commentReplyDefinition,
|
|
78
82
|
};
|
|
79
83
|
|
|
80
84
|
// =============================================================================
|
package/src/resource-keys.ts
CHANGED
|
@@ -29,6 +29,27 @@ export type ResourceKey =
|
|
|
29
29
|
// a bare slug does not identify a document. That field name is a wart to be
|
|
30
30
|
// contained, not propagated — a new key gets the honest name.
|
|
31
31
|
| { type: "companyMdAccessRequests"; orgId: string; docId: string }
|
|
32
|
+
// The comment threads hanging off ONE subject (ADR-CONTRACTS-116).
|
|
33
|
+
//
|
|
34
|
+
// Registered here rather than declared app-locally because THE KEY REGISTRY IS
|
|
35
|
+
// THE SSE COALESCER'S VOCABULARY: a `resource.invalidated` frame names a key,
|
|
36
|
+
// and a key the registry does not know cannot be routed through the coalescer
|
|
37
|
+
// at all. An app-local "commentThreads" would be a second vocabulary the
|
|
38
|
+
// server could never address.
|
|
39
|
+
//
|
|
40
|
+
// The only member with a TWO-SEGMENT identity, and it has to be: a thread
|
|
41
|
+
// list is addressed by (subjectType, subjectId), and the id alone is not
|
|
42
|
+
// enough — subject ids are per-class uuids, so two classes could collide and
|
|
43
|
+
// one subject's invalidation would flush another's cache. `subjectType` is
|
|
44
|
+
// typed `string` and not the closed `CommentSubjectType`: this is a cache
|
|
45
|
+
// vocabulary, `fromQueryKey` reconstructs keys from arbitrary wire strings,
|
|
46
|
+
// and narrowing it here would make that reconstruction a lie.
|
|
47
|
+
| {
|
|
48
|
+
type: "commentThreads";
|
|
49
|
+
orgId: string;
|
|
50
|
+
subjectType: string;
|
|
51
|
+
subjectId: string;
|
|
52
|
+
}
|
|
32
53
|
| { type: "workspace"; orgId: string }
|
|
33
54
|
| { type: "workspaceDomains"; orgId: string }
|
|
34
55
|
| { type: "authSettings"; orgId: string }
|
|
@@ -180,6 +201,12 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
180
201
|
case "companyMdAccessRequests":
|
|
181
202
|
return [key.type, key.orgId, key.docId] as const;
|
|
182
203
|
|
|
204
|
+
// Composite identity — subject CLASS then subject id, in that order, so the
|
|
205
|
+
// two segments read the same way round as the query string that produced
|
|
206
|
+
// them.
|
|
207
|
+
case "commentThreads":
|
|
208
|
+
return [key.type, key.orgId, key.subjectType, key.subjectId] as const;
|
|
209
|
+
|
|
183
210
|
// OrgUnit identity keys (ADR-BE-120)
|
|
184
211
|
case "orgUnit":
|
|
185
212
|
case "orgUnitChildren":
|
|
@@ -250,6 +277,33 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
250
277
|
);
|
|
251
278
|
}
|
|
252
279
|
|
|
280
|
+
// Identities with a COMPOSITE (multi-segment) identity. Checked before the
|
|
281
|
+
// single-field map below, which hard-asserts a two-element `rest` and would
|
|
282
|
+
// otherwise reject these.
|
|
283
|
+
//
|
|
284
|
+
// A map of its own rather than a widening of `identityFields` to a
|
|
285
|
+
// `string | string[]`: the single-segment case is every other key in the
|
|
286
|
+
// union, and making it pay for this one would put a branch in the hot path
|
|
287
|
+
// for no reader's benefit.
|
|
288
|
+
const compositeIdentityFields: Record<string, readonly string[]> = {
|
|
289
|
+
commentThreads: ["subjectType", "subjectId"],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const compositeFields = compositeIdentityFields[type];
|
|
293
|
+
if (compositeFields) {
|
|
294
|
+
// +1 for the orgId that leads every composite key.
|
|
295
|
+
if (rest.length !== compositeFields.length + 1) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`Invalid query key for '${type}': expected [type, orgId, ${compositeFields.join(", ")}], got ${JSON.stringify(queryKey)}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const key: Record<string, string> = { type, orgId: rest[0] };
|
|
301
|
+
compositeFields.forEach((field, index) => {
|
|
302
|
+
key[field] = rest[index + 1];
|
|
303
|
+
});
|
|
304
|
+
return key as ResourceKey;
|
|
305
|
+
}
|
|
306
|
+
|
|
253
307
|
// Identities with extra field
|
|
254
308
|
const identityFields: Record<string, string> = {
|
|
255
309
|
member: "memberId",
|
|
@@ -370,6 +424,20 @@ export function matchesResourceKey(
|
|
|
370
424
|
parsed.unitId !== targetKey.unitId
|
|
371
425
|
)
|
|
372
426
|
return false;
|
|
427
|
+
// Both halves of a composite identity, or a comment thread list on one
|
|
428
|
+
// subject would be invalidated by every other subject in the org.
|
|
429
|
+
if (
|
|
430
|
+
"subjectType" in parsed &&
|
|
431
|
+
"subjectType" in targetKey &&
|
|
432
|
+
parsed.subjectType !== targetKey.subjectType
|
|
433
|
+
)
|
|
434
|
+
return false;
|
|
435
|
+
if (
|
|
436
|
+
"subjectId" in parsed &&
|
|
437
|
+
"subjectId" in targetKey &&
|
|
438
|
+
parsed.subjectId !== targetKey.subjectId
|
|
439
|
+
)
|
|
440
|
+
return false;
|
|
373
441
|
if (
|
|
374
442
|
"scope" in parsed &&
|
|
375
443
|
"scope" in targetKey &&
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
* domain's own authority gate in bulk and returns a tombstone when the
|
|
52
52
|
* reader may no longer see the referenced entity
|
|
53
53
|
*
|
|
54
|
-
* The three members
|
|
54
|
+
* The first three members are the informational half of the company.md
|
|
55
55
|
* request-access lifecycle. Note what is NOT here:
|
|
56
56
|
* `companyMd.access_request_pending` is an ACTION ITEM — the owner can resolve
|
|
57
57
|
* it, so it is standing state, and modelling it here as well would give one
|
|
@@ -64,6 +64,18 @@ export const USER_NOTIFICATION_KINDS = [
|
|
|
64
64
|
"companyMd.access_request_approved",
|
|
65
65
|
/** Your request was declined. */
|
|
66
66
|
"companyMd.access_request_denied",
|
|
67
|
+
/**
|
|
68
|
+
* You were named in a comment (ADR-CONTRACTS-116).
|
|
69
|
+
*
|
|
70
|
+
* A fact you can only look at, so it belongs here and not in `ActionItemKind`
|
|
71
|
+
* — being mentioned asks nothing of you that a system can call resolved. The
|
|
72
|
+
* producing domain emits at most ONE row per person per comment: a mentioned
|
|
73
|
+
* participant gets this and NOT `comment.reply`, because the mention is the
|
|
74
|
+
* more specific fact and the one they were addressed by.
|
|
75
|
+
*/
|
|
76
|
+
"comment.mention",
|
|
77
|
+
/** A thread you took part in got a new comment. Never emitted for an edit. */
|
|
78
|
+
"comment.reply",
|
|
67
79
|
] as const;
|
|
68
80
|
|
|
69
81
|
export type UserNotificationKind = (typeof USER_NOTIFICATION_KINDS)[number];
|