@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.
- package/package.json +10 -3
- package/src/__tests__/resource-keys.test.ts +59 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +540 -1
- 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 +7 -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/org/schemas.ts +23 -0
- package/src/resource-keys.ts +68 -0
- 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'],
|