@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
|
@@ -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",
|