@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.
Files changed (31) hide show
  1. package/package.json +9 -2
  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 +93 -2
  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 +1 -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/resource-keys.ts +68 -0
  31. package/src/user-notifications/kinds.ts +13 -1
@@ -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>;
@@ -46,6 +46,7 @@ export const openApiRoutes = {
46
46
  '/api/company-md/docs/{id}/context-bank/upload': ['POST'],
47
47
  '/api/company-md/docs/{id}/context-bank/{contextDocId}': ['DELETE'],
48
48
  '/api/company-md/docs/{id}/context-bank/{contextDocId}/order': ['PATCH'],
49
+ '/api/company-md/docs/{id}/mentionable': ['GET'],
49
50
  '/api/company-md/docs/{id}/sharing': ['GET'],
50
51
  '/api/company-md/docs/{id}/sharing/acl': ['POST'],
51
52
  '/api/company-md/docs/{id}/sharing/acl/{aclId}': ['DELETE', 'PUT'],
package/src/index.ts CHANGED
@@ -322,6 +322,48 @@ export type {
322
322
  UserNotificationKind,
323
323
  } from "./user-notifications/index";
324
324
 
325
+ // Comment wire contract — where a thread hangs (the anchor union), what a
326
+ // thread and its comments look like on the wire, and who a comment may name.
327
+ // The anchor is the single most load-bearing shape in this package: the backend
328
+ // validates it and then stores opaque jsonb it never interprets, so app/
329
+ // contracts drift on it has NO server-side tripwire and mis-places comments
330
+ // silently. Response vocabulary only — request bodies stay backend-side.
331
+ // See src/comments/README.md.
332
+ // @see ADR-CONTRACTS-116, ADR-CONT-029
333
+ export {
334
+ COMMENT_ANCHOR_TYPES,
335
+ COMMENT_SUBJECT_TYPES,
336
+ COMMENT_THREAD_STATUSES,
337
+ } from "./comments/index";
338
+
339
+ export {
340
+ CommentAnchorSchema,
341
+ CommentAnchorTypeSchema,
342
+ CommentMentionSchema,
343
+ CommentSchema,
344
+ CommentSubjectTypeSchema,
345
+ CommentThreadListResponseSchema,
346
+ CommentThreadSchema,
347
+ CommentThreadStatusSchema,
348
+ CommentThreadSummarySchema,
349
+ MentionableCandidateSchema,
350
+ MentionableResponseSchema,
351
+ } from "./comments/index";
352
+
353
+ export type {
354
+ CommentAnchor,
355
+ CommentAnchorType,
356
+ CommentMention,
357
+ CommentProjection,
358
+ CommentSubjectType,
359
+ CommentThread,
360
+ CommentThreadListResponse,
361
+ CommentThreadStatus,
362
+ CommentThreadSummary,
363
+ MentionableCandidate,
364
+ MentionableResponse,
365
+ } from "./comments/index";
366
+
325
367
  // Chat domain types
326
368
  // @see PRD-00142 for share chat design rationale
327
369
  export type {
@@ -10,6 +10,10 @@ exports[`NOTIFICATION_DEFINITIONS > titles every kind 1`] = `
10
10
  "chat.shared · Long title": "A chat has been shared with you",
11
11
  "chat.shared · No preview": "A chat has been shared with you",
12
12
  "chat.shared · Short": "A chat has been shared with you",
13
+ "comment.mention · Document-level": "Jordan Lee mentioned you in a comment",
14
+ "comment.mention · Text-anchored": "Jordan Lee mentioned you in a comment",
15
+ "comment.reply · Document-level": "Alex Rivera replied in a thread you are part of",
16
+ "comment.reply · Text-anchored": "Alex Rivera replied in a thread you are part of",
13
17
  "companyMd.access_request_approved · Editor": "Your access request was approved",
14
18
  "companyMd.access_request_approved · Editor with message": "Your access request was approved",
15
19
  "companyMd.access_request_approved · Viewer": "Your access request was approved",