@company-semantics/contracts 45.1.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.
Files changed (32) hide show
  1. package/package.json +10 -3
  2. package/src/__tests__/resource-keys.test.ts +59 -0
  3. package/src/api/generated-spec-hash.ts +2 -2
  4. package/src/api/generated.ts +540 -1
  5. package/src/comments/README.md +115 -0
  6. package/src/comments/__tests__/README.md +49 -0
  7. package/src/comments/__tests__/anchor-corpus.test.ts +202 -0
  8. package/src/comments/__tests__/fixtures/README.md +58 -0
  9. package/src/comments/__tests__/fixtures/comment-anchors.json +94 -0
  10. package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +9 -0
  11. package/src/comments/__tests__/schemas.test.ts +274 -0
  12. package/src/comments/anchor.ts +120 -0
  13. package/src/comments/index.ts +43 -0
  14. package/src/comments/schemas.ts +227 -0
  15. package/src/generated/openapi-routes.ts +7 -0
  16. package/src/index.ts +42 -0
  17. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +4 -0
  18. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +741 -0
  19. package/src/notifications/__tests__/definition.test.ts +4 -3
  20. package/src/notifications/__tests__/fixtures.ts +34 -0
  21. package/src/notifications/__tests__/kinds.test.ts +11 -4
  22. package/src/notifications/__tests__/registry.test.ts +7 -5
  23. package/src/notifications/__tests__/render-snapshot.test.ts +36 -0
  24. package/src/notifications/kinds/comment-mention.ts +82 -0
  25. package/src/notifications/kinds/comment-reply.ts +79 -0
  26. package/src/notifications/kinds/index.ts +2 -0
  27. package/src/notifications/kinds.ts +12 -3
  28. package/src/notifications/payloads.ts +49 -0
  29. package/src/notifications/registry.ts +6 -2
  30. package/src/org/schemas.ts +23 -0
  31. package/src/resource-keys.ts +68 -0
  32. package/src/user-notifications/kinds.ts +13 -1
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The thread / comment / mention projections, held to the invariants their
3
+ * JSDoc claims (ADR-CONTRACTS-116).
4
+ *
5
+ * Every negative test mutates ONE field of a well-formed factory result. A
6
+ * hand-built broken object can pass for the wrong reason — it fails on the
7
+ * field nobody was testing — and these shapes have enough required fields for
8
+ * that to happen easily.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import {
13
+ COMMENT_SUBJECT_TYPES,
14
+ COMMENT_THREAD_STATUSES,
15
+ CommentMentionSchema,
16
+ CommentSchema,
17
+ CommentThreadListResponseSchema,
18
+ CommentThreadSchema,
19
+ CommentThreadStatusSchema,
20
+ MentionableResponseSchema,
21
+ } from "../schemas.js";
22
+
23
+ const THREAD_ID = "11111111-1111-4111-8111-111111111111";
24
+ const COMMENT_ID = "22222222-2222-4222-8222-222222222222";
25
+ const USER_ID = "33333333-3333-4333-8333-333333333333";
26
+ const DOC_ID = "44444444-4444-4444-8444-444444444444";
27
+
28
+ function makeMention(over: Record<string, unknown> = {}) {
29
+ return {
30
+ userId: USER_ID,
31
+ startOffset: 0,
32
+ endOffset: 9,
33
+ displayName: "Sam Chen",
34
+ ...over,
35
+ };
36
+ }
37
+
38
+ function makeComment(over: Record<string, unknown> = {}) {
39
+ return {
40
+ id: COMMENT_ID,
41
+ threadId: THREAD_ID,
42
+ authorUserId: USER_ID,
43
+ body: "Should this say Q3 or Q4?",
44
+ editedAt: null,
45
+ deletedAt: null,
46
+ createdAt: "2026-08-01T12:00:00.000Z",
47
+ mentions: [],
48
+ ...over,
49
+ };
50
+ }
51
+
52
+ function makeThread(over: Record<string, unknown> = {}) {
53
+ return {
54
+ id: THREAD_ID,
55
+ subjectType: "company_md",
56
+ subjectId: DOC_ID,
57
+ anchorType: "text",
58
+ anchor: {
59
+ type: "text",
60
+ v: 1,
61
+ quote: "ship the quarterly goals",
62
+ prefix: "agreed to ",
63
+ suffix: " by March",
64
+ },
65
+ status: "open",
66
+ createdByUserId: USER_ID,
67
+ resolvedByUserId: null,
68
+ resolvedAt: null,
69
+ createdAt: "2026-08-01T12:00:00.000Z",
70
+ updatedAt: "2026-08-01T12:00:00.000Z",
71
+ comments: [makeComment()],
72
+ ...over,
73
+ };
74
+ }
75
+
76
+ describe("CommentThreadStatus", () => {
77
+ it("is exactly open and resolved", () => {
78
+ expect([...COMMENT_THREAD_STATUSES]).toEqual(["open", "resolved"]);
79
+ });
80
+
81
+ it("rejects a thread status outside the enum", () => {
82
+ // Notably `deleted`: a thread whose comments are all soft-deleted is still
83
+ // an ordinary resolvable thread, and inventing a third state client-side
84
+ // would give the resolve/reopen transition somewhere it can never return
85
+ // from.
86
+ expect(CommentThreadStatusSchema.safeParse("deleted").success).toBe(false);
87
+ expect(CommentThreadStatusSchema.safeParse("").success).toBe(false);
88
+ });
89
+
90
+ it("rejects a thread carrying a status outside the enum", () => {
91
+ expect(
92
+ CommentThreadSchema.safeParse(makeThread({ status: "archived" })).success,
93
+ ).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("CommentSubjectType", () => {
98
+ it("is closed, not a free string", () => {
99
+ // Sized to the ACL entity types that admit comments. A free string here
100
+ // would let a client address a subject class the server has no policy for
101
+ // and read the 404 as "not found" rather than "never supported".
102
+ expect([...COMMENT_SUBJECT_TYPES]).toEqual([
103
+ "company_md",
104
+ "strategy_doc",
105
+ "work_item",
106
+ "meeting_recording",
107
+ ]);
108
+ expect(
109
+ CommentThreadSchema.safeParse(makeThread({ subjectType: "chat" }))
110
+ .success,
111
+ ).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe("CommentSchema and the redaction invariant", () => {
116
+ it("parses a well-formed comment", () => {
117
+ expect(CommentSchema.safeParse(makeComment()).success).toBe(true);
118
+ });
119
+
120
+ it("parses a soft-deleted comment projection with a null body", () => {
121
+ // THE TOMBSTONE. A soft-deleted comment is a REDACTED PROJECTION, not a
122
+ // removed row: body null, mentions empty, everything else retained. If this
123
+ // stops parsing, a single deleted comment fails the whole thread load —
124
+ // and the tombstone is what keeps the surrounding replies in order and
125
+ // keeps an all-deleted thread distinguishable from an empty one.
126
+ const parsed = CommentSchema.safeParse(
127
+ makeComment({
128
+ body: null,
129
+ deletedAt: "2026-08-02T09:00:00.000Z",
130
+ mentions: [],
131
+ }),
132
+ );
133
+ expect(parsed.success).toBe(true);
134
+ });
135
+
136
+ it("parses an unattributed comment — a tombstoned author is not an error", () => {
137
+ expect(
138
+ CommentSchema.safeParse(makeComment({ authorUserId: null })).success,
139
+ ).toBe(true);
140
+ });
141
+
142
+ it("requires the mentions array — absent is not the same as empty", () => {
143
+ const { mentions: _dropped, ...withoutMentions } = makeComment();
144
+ expect(CommentSchema.safeParse(withoutMentions).success).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("CommentMentionSchema offsets", () => {
149
+ it("parses a mention whose range is null", () => {
150
+ // Null on every row today — `comment_mentions` has no offset columns yet.
151
+ // A client must render these as "highlight nothing", so they must parse.
152
+ expect(
153
+ CommentMentionSchema.safeParse(
154
+ makeMention({ startOffset: null, endOffset: null }),
155
+ ).success,
156
+ ).toBe(true);
157
+ });
158
+
159
+ it("measures in UTF-16 CODE UNITS, so an emoji costs two", () => {
160
+ // The convention, pinned by arithmetic rather than by comment. `"🎉 hi "`
161
+ // is SIX code units — the emoji is a surrogate pair — so the mention that
162
+ // follows it starts at 6. A client measuring in code points would compute 5
163
+ // and highlight one character to the left, and would keep sliding by one
164
+ // per preceding emoji. Nothing normalizes the body on this path, which is
165
+ // what makes the count on the wire the same count a client can reproduce.
166
+ const body = "🎉 hi @Sam Chen";
167
+ expect(body.length).toBe(15);
168
+ const start = body.indexOf("@");
169
+ expect(start).toBe(6);
170
+
171
+ const comment = CommentSchema.safeParse(
172
+ makeComment({
173
+ body,
174
+ mentions: [makeMention({ startOffset: start, endOffset: body.length })],
175
+ }),
176
+ );
177
+ expect(comment.success).toBe(true);
178
+ // endOffset is EXCLUSIVE: slicing [start, end) yields exactly the mention.
179
+ expect(body.slice(start, body.length)).toBe("@Sam Chen");
180
+ });
181
+
182
+ it("rejects a negative offset", () => {
183
+ expect(
184
+ CommentMentionSchema.safeParse(makeMention({ startOffset: -1 })).success,
185
+ ).toBe(false);
186
+ });
187
+
188
+ it("rejects a fractional offset", () => {
189
+ // A code-unit index is an integer by construction; a fraction means someone
190
+ // measured in something else.
191
+ expect(
192
+ CommentMentionSchema.safeParse(makeMention({ endOffset: 2.5 })).success,
193
+ ).toBe(false);
194
+ });
195
+ });
196
+
197
+ describe("CommentThreadSchema", () => {
198
+ it("parses a well-formed thread with its comments", () => {
199
+ expect(CommentThreadSchema.safeParse(makeThread()).success).toBe(true);
200
+ });
201
+
202
+ it("parses a document-anchored thread", () => {
203
+ expect(
204
+ CommentThreadSchema.safeParse(
205
+ makeThread({
206
+ anchorType: "document",
207
+ anchor: { type: "document", v: 1 },
208
+ }),
209
+ ).success,
210
+ ).toBe(true);
211
+ });
212
+
213
+ it("rejects a thread whose anchor is invalid", () => {
214
+ // The thread is the only place an anchor reaches a client, so a thread must
215
+ // not parse around a broken one — that would hand the app an anchor it
216
+ // cannot resolve while telling it the response was fine.
217
+ expect(
218
+ CommentThreadSchema.safeParse(
219
+ makeThread({ anchor: { type: "text", v: 1, prefix: "", suffix: "" } }),
220
+ ).success,
221
+ ).toBe(false);
222
+ });
223
+
224
+ it("keeps tombstones in the comment list rather than filtering them", () => {
225
+ const thread = CommentThreadSchema.parse(
226
+ makeThread({
227
+ comments: [
228
+ makeComment({
229
+ id: "55555555-5555-4555-8555-555555555555",
230
+ body: null,
231
+ deletedAt: "2026-08-02T09:00:00.000Z",
232
+ }),
233
+ makeComment(),
234
+ ],
235
+ }),
236
+ );
237
+ expect(thread.comments).toHaveLength(2);
238
+ expect(thread.comments[0].body).toBeNull();
239
+ });
240
+ });
241
+
242
+ describe("CommentThreadListResponseSchema", () => {
243
+ it("parses an empty subject", () => {
244
+ expect(
245
+ CommentThreadListResponseSchema.safeParse({ threads: [] }).success,
246
+ ).toBe(true);
247
+ });
248
+
249
+ it("parses a subject with threads", () => {
250
+ expect(
251
+ CommentThreadListResponseSchema.safeParse({ threads: [makeThread()] })
252
+ .success,
253
+ ).toBe(true);
254
+ });
255
+ });
256
+
257
+ describe("MentionableResponseSchema", () => {
258
+ it("parses a candidate list", () => {
259
+ expect(
260
+ MentionableResponseSchema.safeParse({
261
+ items: [{ userId: USER_ID, displayName: "Sam Chen", avatarUrl: null }],
262
+ }).success,
263
+ ).toBe(true);
264
+ });
265
+
266
+ it("carries no access level — a picker is not an access-inspection surface", () => {
267
+ // The candidates ARE the document's readers. Returning the band each one
268
+ // holds would turn an autocomplete into a way to enumerate who can do what,
269
+ // so the field must stay absent from the shape rather than merely unset.
270
+ expect(
271
+ Object.keys(MentionableResponseSchema.shape.items.element.shape).sort(),
272
+ ).toEqual(["avatarUrl", "displayName", "userId"]);
273
+ });
274
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The comment anchor — where on a subject a thread hangs (ADR-CONTRACTS-116).
3
+ *
4
+ * THE ANCHOR IS THE CONTRACT BETWEEN THE CLIENT THAT WRITES IT AND THE CLIENT
5
+ * THAT LATER RESOLVES IT. The backend is a PASSTHROUGH: it validates this shape
6
+ * at the route boundary, stores the result as opaque jsonb, and interprets
7
+ * nothing (backend ADR slug polymorphic-comment-threads §4). So a disagreement
8
+ * between the writing client and the resolving client about what these fields
9
+ * MEAN produces silently mis-placed comments with no server-side tripwire — the
10
+ * write succeeds, the read succeeds, and the highlight lands on the wrong text.
11
+ * That is why the shape is published here rather than declared app-locally.
12
+ *
13
+ * `v` is the evolution hatch and the only thing that bounds the risk above: a
14
+ * client that meets an anchor it cannot resolve sees an unfamiliar `v` and can
15
+ * degrade deliberately, instead of guessing at fields it half-recognises. New
16
+ * anchor shapes therefore arrive as new `v` variants, never as a migration of an
17
+ * existing one.
18
+ *
19
+ * Zod-canonical: the schema is the source of truth, the type is inferred.
20
+ *
21
+ * MIRRORED, NOT INVENTED. Every bound below is the backend route boundary's
22
+ * (`src/api/http/routes/comments/comments.schemas.ts`), and the accept/reject
23
+ * corpus at `./__tests__/fixtures/comment-anchors.json` is copied byte-for-byte
24
+ * from that suite's fixtures. Editing that copy HERE fails the anchor suite —
25
+ * its digest is pinned beside it. A change made at the BOUNDARY is not caught
26
+ * from this repo; a single-repo CI run cannot see that file, and this package
27
+ * must not depend on the backend to look. ADR-CONTRACTS-116 records which half
28
+ * is gated and where the missing gate belongs.
29
+ */
30
+ import { z } from "zod";
31
+
32
+ /**
33
+ * A base64 `Y.RelativePosition` payload, encoded by the client.
34
+ *
35
+ * Bounded so an anchor can never smuggle document-scale content through a field
36
+ * nothing on the server reads. The backend never decodes it — the bound and the
37
+ * charset are the only things anyone checks, so they have to be checked here
38
+ * too or a client can write a position no other client can decode.
39
+ */
40
+ const RelativePositionSchema = z
41
+ .string()
42
+ .min(1)
43
+ .max(512)
44
+ .regex(/^[A-Za-z0-9+/]+={0,2}$/, "must be base64");
45
+
46
+ /**
47
+ * The thread hangs off the subject AS A WHOLE — a comment on the document, not
48
+ * on a passage in it.
49
+ *
50
+ * Strict: a document anchor carrying `quote`/`prefix`/`relStart` is refused
51
+ * rather than silently accepted with the extra fields ignored. A client that
52
+ * sends those meant to anchor to text and got the discriminator wrong; storing
53
+ * the mistake would produce a thread that can never be placed.
54
+ *
55
+ * SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00922, must_log).
56
+ * The reference block writes this variant as
57
+ * `z.object({ type: z.literal('document'), v: z.literal(1) }).strict()`.
58
+ * What ships below is the same schema in two different spellings, not a
59
+ * different schema: `z.strictObject` is zod v4's idiom for `z.object().strict()`,
60
+ * and the double quotes are this repo's Prettier default (there is no
61
+ * `.prettierrc`, so `singleQuote` is false and `pnpm format:check` rewrites any
62
+ * single-quoted string here). Recorded because the reference is marked
63
+ * authoritative, and a reader diffing the two should be able to tell at a glance
64
+ * that nothing about what is accepted or refused changed.
65
+ */
66
+ const DocumentAnchorSchema = z.strictObject({
67
+ type: z.literal("document"),
68
+ v: z.literal(1),
69
+ });
70
+
71
+ /**
72
+ * A text-anchored thread.
73
+ *
74
+ * `quote` is REQUIRED and is the durable fallback — it is what a resolving
75
+ * client re-finds the passage by when the relative positions no longer resolve,
76
+ * and it is also the composer's `Comment on "…"` label. An anchor with no quote
77
+ * is unplaceable the first time the document is edited, so it is refused.
78
+ *
79
+ * `relStart`/`relEnd` are OPTIONAL by design: a comment authored from a card or
80
+ * a list view has no attached collab session to encode a position from, and has
81
+ * only the quote path. Optional here means "this client could not produce one",
82
+ * never "this client chose not to".
83
+ *
84
+ * `prefix`/`suffix` disambiguate a quote that appears more than once. They are
85
+ * required-but-may-be-empty rather than optional, so "there is no surrounding
86
+ * context" and "this client forgot to send it" stay distinguishable.
87
+ */
88
+ const TextAnchorSchema = z.strictObject({
89
+ type: z.literal("text"),
90
+ v: z.literal(1),
91
+ relStart: RelativePositionSchema.optional(),
92
+ relEnd: RelativePositionSchema.optional(),
93
+ quote: z.string().min(1).max(2000),
94
+ prefix: z.string().max(64),
95
+ suffix: z.string().max(64),
96
+ });
97
+
98
+ /**
99
+ * The anchor types, as a vocabulary tuple.
100
+ *
101
+ * Mirrors the union's discriminants, and is what `CommentThreadSummarySchema`'s
102
+ * denormalised `anchorType` column is checked against — the server projects the
103
+ * discriminant out of the jsonb so a client can filter threads without parsing
104
+ * every anchor.
105
+ */
106
+ export const COMMENT_ANCHOR_TYPES = ["document", "text"] as const;
107
+ export const CommentAnchorTypeSchema = z.enum(COMMENT_ANCHOR_TYPES);
108
+ export type CommentAnchorType = z.infer<typeof CommentAnchorTypeSchema>;
109
+
110
+ /**
111
+ * The opaque, versioned anchor payload.
112
+ *
113
+ * Discriminated on `type` with STRICT variants, which is what makes the two
114
+ * failure modes above real refusals rather than silent coercions.
115
+ */
116
+ export const CommentAnchorSchema = z.discriminatedUnion("type", [
117
+ DocumentAnchorSchema,
118
+ TextAnchorSchema,
119
+ ]);
120
+ export type CommentAnchor = z.infer<typeof CommentAnchorSchema>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * comments/ — the published wire contract for comment threads: the anchor
3
+ * union, the thread/comment/mention read projections, and the mention picker's
4
+ * response.
5
+ *
6
+ * See ./README.md for the domain, and ADR-CONTRACTS-116 for why each of these
7
+ * meets the promotion rule — and for what is deliberately NOT here (request
8
+ * bodies, per ADR-CONT-029).
9
+ */
10
+
11
+ export {
12
+ COMMENT_ANCHOR_TYPES,
13
+ CommentAnchorTypeSchema,
14
+ CommentAnchorSchema,
15
+ } from "./anchor";
16
+
17
+ export type { CommentAnchor, CommentAnchorType } from "./anchor";
18
+
19
+ export {
20
+ COMMENT_SUBJECT_TYPES,
21
+ COMMENT_THREAD_STATUSES,
22
+ CommentMentionSchema,
23
+ CommentSchema,
24
+ CommentSubjectTypeSchema,
25
+ CommentThreadListResponseSchema,
26
+ CommentThreadSchema,
27
+ CommentThreadStatusSchema,
28
+ CommentThreadSummarySchema,
29
+ MentionableCandidateSchema,
30
+ MentionableResponseSchema,
31
+ } from "./schemas";
32
+
33
+ export type {
34
+ CommentMention,
35
+ CommentProjection,
36
+ CommentSubjectType,
37
+ CommentThread,
38
+ CommentThreadListResponse,
39
+ CommentThreadStatus,
40
+ CommentThreadSummary,
41
+ MentionableCandidate,
42
+ MentionableResponse,
43
+ } from "./schemas";
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The published read shapes of the comment surface (ADR-CONTRACTS-116).
3
+ *
4
+ * RESPONSE VOCABULARY ONLY. Request bodies stay backend-side per ADR-CONT-029 —
5
+ * a create/edit body is a transport concern, and one of them (the mention set)
6
+ * is the exact thing a client must not be able to influence: the server derives
7
+ * who may be mentioned from `effective_acl_grants` and never from what the
8
+ * client sends.
9
+ *
10
+ * Zod-canonical: the schema is the source of truth, the type is inferred.
11
+ *
12
+ * MIRRORED, NOT INVENTED. Every field below matches the backend route
13
+ * boundary's response schemas (`src/api/http/routes/comments/comments.schemas.ts`
14
+ * and `.../company-md/mentionable.schemas.ts`) field-for-field, and therefore
15
+ * matches the already-generated `components["schemas"]` view in `../api`. Two
16
+ * descriptions of one wire shape that disagree is worse than one, so where the
17
+ * backend is looser than looks right — `userId` is `z.string()` and not a uuid,
18
+ * mention offsets are nullable — this mirrors the backend and says why.
19
+ */
20
+ import { z } from "zod";
21
+ import { CommentAnchorSchema, CommentAnchorTypeSchema } from "./anchor";
22
+
23
+ // =============================================================================
24
+ // Vocabulary
25
+ // =============================================================================
26
+
27
+ /**
28
+ * A thread is open or resolved. There is no third state and deliberately no
29
+ * `deleted`: a thread whose comments are all soft-deleted is still a resolvable
30
+ * thread, and its tombstones keep their place (see `CommentSchema`).
31
+ */
32
+ export const COMMENT_THREAD_STATUSES = ["open", "resolved"] as const;
33
+ export const CommentThreadStatusSchema = z.enum(COMMENT_THREAD_STATUSES);
34
+ export type CommentThreadStatus = z.infer<typeof CommentThreadStatusSchema>;
35
+
36
+ /**
37
+ * What a thread can hang off.
38
+ *
39
+ * A CLOSED enum rather than a free string, sized to the ACL entity types that
40
+ * actually admit comments today. The transport accepts this vocabulary and the
41
+ * backend's fail-closed subject-policy map decides ADMISSION separately, so a
42
+ * subject type present here is addressable, not necessarily commentable — which
43
+ * is why a client must still handle the 404 rather than infer permission from
44
+ * membership in this list.
45
+ */
46
+ export const COMMENT_SUBJECT_TYPES = [
47
+ "company_md",
48
+ "strategy_doc",
49
+ "work_item",
50
+ "meeting_recording",
51
+ ] as const;
52
+ export const CommentSubjectTypeSchema = z.enum(COMMENT_SUBJECT_TYPES);
53
+ export type CommentSubjectType = z.infer<typeof CommentSubjectTypeSchema>;
54
+
55
+ // =============================================================================
56
+ // Mentions
57
+ // =============================================================================
58
+
59
+ /**
60
+ * ONE mention on a comment, as the app renders it.
61
+ *
62
+ * OFFSETS ARE ZERO-BASED UTF-16 CODE UNITS INTO THE EXACT COMMENT BODY STRING,
63
+ * WITH `endOffset` EXCLUSIVE — i.e. plain JavaScript string coordinates, the
64
+ * same ones a `textarea`'s `selectionStart` and CodeMirror report. That is the
65
+ * whole convention and it is load-bearing in two directions: an emoji or any
66
+ * other astral character counts as TWO units, not one, so a client that
67
+ * measures in code POINTS (`[...body].length`, `Intl.Segmenter`) will produce
68
+ * ranges that slide off the mention by one per preceding emoji. And NO UNICODE
69
+ * NORMALIZATION happens anywhere on this path (backend ADR slug
70
+ * comment-mention-identity) — normalising the body before measuring would
71
+ * change its length and desynchronise every offset after the first composed
72
+ * character.
73
+ *
74
+ * IDENTITY RENDERS FROM THIS ROW, NEVER FROM THE BODY CHARACTERS UNDER THE
75
+ * RANGE. A stale or forged range can therefore only mis-HIGHLIGHT; it can never
76
+ * misattribute a mention to the wrong person.
77
+ *
78
+ * `startOffset`/`endOffset` are NULLABLE, and null on every row today:
79
+ * `comment_mentions` has no offset columns yet, so the ranges the server
80
+ * validates in memory have nowhere to persist. The fields are carried in the
81
+ * TARGET shape so a client is written against the final contract once — treat
82
+ * null as "this mention is in the body somewhere, highlight nothing", not as an
83
+ * error.
84
+ *
85
+ * `displayName` is resolved AT READ TIME from the current user record and is
86
+ * never stored on the mention row: a rename re-renders with no backfill, and an
87
+ * identity the reader can no longer resolve arrives as a neutral placeholder
88
+ * rather than failing the whole thread load.
89
+ *
90
+ * `userId` is `z.string()` and not `.uuid()` on purpose — it mirrors the
91
+ * backend boundary exactly. Tightening it here would make this package reject
92
+ * responses the server considers valid, which is a client-side outage for no
93
+ * security gain.
94
+ */
95
+ export const CommentMentionSchema = z.object({
96
+ userId: z.string(),
97
+ startOffset: z.number().int().min(0).nullable(),
98
+ endOffset: z.number().int().min(0).nullable(),
99
+ displayName: z.string(),
100
+ });
101
+ export type CommentMention = z.infer<typeof CommentMentionSchema>;
102
+
103
+ // =============================================================================
104
+ // Comments
105
+ // =============================================================================
106
+
107
+ /**
108
+ * One comment as the app renders it.
109
+ *
110
+ * THE REDACTION INVARIANT: `body` is null EXACTLY WHEN `deletedAt` is set, and a
111
+ * soft-deleted comment also arrives with `mentions` empty. A deleted comment is
112
+ * a REDACTED PROJECTION, not a removed row — the tombstone keeps its position in
113
+ * the (createdAt ASC, id ASC) sequence, so replies above and below it still read
114
+ * in order, and a thread whose comments were all deleted stays distinguishable
115
+ * from a thread that never had any.
116
+ *
117
+ * The invariant is DOCUMENTED here and enforced by the writer, not by a Zod
118
+ * refinement. A refinement would make this package refuse a response the server
119
+ * is willing to emit, and it cannot be expressed in the OpenAPI surface either —
120
+ * so it would be a third description of the wire shape that the parity guards
121
+ * cannot check. `./__tests__/schemas.test.ts` pins that the redacted projection
122
+ * parses.
123
+ *
124
+ * `authorUserId` is nullable for the same tombstoning reason: an author whose
125
+ * user record is gone leaves the comment readable and unattributed.
126
+ *
127
+ * The inferred type is `CommentProjection`, not `Comment`, for the reason
128
+ * `NotificationElement` is not `Element` (`../notifications/content`): `Comment`
129
+ * is a DOM global, and a bare `Comment` on the root barrel takes it away from
130
+ * any consumer file that imports it. `CommentProjection` is also the truer name
131
+ * — the redaction rule above is what makes this a projection rather than a row.
132
+ */
133
+ export const CommentSchema = z.object({
134
+ id: z.string(),
135
+ threadId: z.string(),
136
+ authorUserId: z.string().nullable(),
137
+ body: z.string().nullable(),
138
+ editedAt: z.string().nullable(),
139
+ deletedAt: z.string().nullable(),
140
+ createdAt: z.string(),
141
+ mentions: z.array(CommentMentionSchema),
142
+ });
143
+ export type CommentProjection = z.infer<typeof CommentSchema>;
144
+
145
+ // =============================================================================
146
+ // Threads
147
+ // =============================================================================
148
+
149
+ /**
150
+ * A thread WITHOUT its comments — the shape a resolve/reopen transition returns.
151
+ *
152
+ * `anchorType` is the anchor's discriminant, denormalised out of the jsonb by
153
+ * the server. It is redundant with `anchor.type` and that is the point: a client
154
+ * can bucket threads into document-level and text-level without parsing every
155
+ * anchor, and the two are written together so they cannot disagree.
156
+ */
157
+ export const CommentThreadSummarySchema = z.object({
158
+ id: z.string(),
159
+ subjectType: CommentSubjectTypeSchema,
160
+ subjectId: z.string(),
161
+ anchorType: CommentAnchorTypeSchema,
162
+ anchor: CommentAnchorSchema,
163
+ status: CommentThreadStatusSchema,
164
+ createdByUserId: z.string().nullable(),
165
+ resolvedByUserId: z.string().nullable(),
166
+ resolvedAt: z.string().nullable(),
167
+ createdAt: z.string(),
168
+ updatedAt: z.string(),
169
+ });
170
+ export type CommentThreadSummary = z.infer<typeof CommentThreadSummarySchema>;
171
+
172
+ /**
173
+ * A thread with ALL its comments, oldest-first by (createdAt ASC, id ASC).
174
+ *
175
+ * Soft-deleted comments appear REDACTED, never filtered out — filtering them
176
+ * would renumber the conversation under the reader and make "…replying to the
177
+ * comment above" false.
178
+ */
179
+ export const CommentThreadSchema = CommentThreadSummarySchema.extend({
180
+ comments: z.array(CommentSchema),
181
+ });
182
+ export type CommentThread = z.infer<typeof CommentThreadSchema>;
183
+
184
+ /** `GET /api/comments` — every thread on the subject the caller may see. */
185
+ export const CommentThreadListResponseSchema = z.object({
186
+ threads: z.array(CommentThreadSchema),
187
+ });
188
+ export type CommentThreadListResponse = z.infer<
189
+ typeof CommentThreadListResponseSchema
190
+ >;
191
+
192
+ // =============================================================================
193
+ // The mention picker
194
+ // =============================================================================
195
+
196
+ /**
197
+ * One offerable mention target.
198
+ *
199
+ * Deliberately NO email and no access level: this is a picker, not an
200
+ * access-inspection surface, and the candidate set is already the document's
201
+ * readers — so returning the band each one holds would turn an autocomplete into
202
+ * a way to enumerate who can do what.
203
+ *
204
+ * `userId` is what the mention row will carry; `displayName` is resolved fresh
205
+ * on every read and never frozen into a stored row, exactly as on
206
+ * `CommentMentionSchema`.
207
+ */
208
+ export const MentionableCandidateSchema = z.object({
209
+ userId: z.string(),
210
+ displayName: z.string(),
211
+ avatarUrl: z.string().nullable(),
212
+ });
213
+ export type MentionableCandidate = z.infer<typeof MentionableCandidateSchema>;
214
+
215
+ /**
216
+ * `GET /api/company-md/docs/{id}/mentionable`.
217
+ *
218
+ * THE CANDIDATES ARE THE DOCUMENT'S READERS, derived server-side from
219
+ * `effective_acl_grants` — so the picker and the set of people a mention can
220
+ * actually notify agree by construction, and a client cannot widen it. Ordering
221
+ * is `(displayName ASC, userId ASC)`; the id tie-break is what makes the page
222
+ * deterministic when two members share a display name.
223
+ */
224
+ export const MentionableResponseSchema = z.object({
225
+ items: z.array(MentionableCandidateSchema),
226
+ });
227
+ export type MentionableResponse = z.infer<typeof MentionableResponseSchema>;
@@ -21,6 +21,12 @@ export const openApiRoutes = {
21
21
  '/api/chats/{id}/messages': ['POST'],
22
22
  '/api/chats/{id}/messages/{messageId}': ['DELETE'],
23
23
  '/api/chats/{id}/pin': ['DELETE', 'POST'],
24
+ '/api/comments': ['GET'],
25
+ '/api/comments/threads': ['POST'],
26
+ '/api/comments/threads/{threadId}/comments': ['POST'],
27
+ '/api/comments/threads/{threadId}/reopen': ['POST'],
28
+ '/api/comments/threads/{threadId}/resolve': ['POST'],
29
+ '/api/comments/{commentId}': ['DELETE', 'PATCH'],
24
30
  '/api/company-md/access-requests/{id}/approve': ['POST'],
25
31
  '/api/company-md/access-requests/{id}/deny': ['POST'],
26
32
  '/api/company-md/context-bank': ['POST'],
@@ -40,6 +46,7 @@ export const openApiRoutes = {
40
46
  '/api/company-md/docs/{id}/context-bank/upload': ['POST'],
41
47
  '/api/company-md/docs/{id}/context-bank/{contextDocId}': ['DELETE'],
42
48
  '/api/company-md/docs/{id}/context-bank/{contextDocId}/order': ['PATCH'],
49
+ '/api/company-md/docs/{id}/mentionable': ['GET'],
43
50
  '/api/company-md/docs/{id}/sharing': ['GET'],
44
51
  '/api/company-md/docs/{id}/sharing/acl': ['POST'],
45
52
  '/api/company-md/docs/{id}/sharing/acl/{aclId}': ['DELETE', 'PUT'],