@actuate-media/cms-core 0.21.0 → 0.22.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 (105) hide show
  1. package/dist/__tests__/actions/document-crud.test.js +74 -2
  2. package/dist/__tests__/actions/document-crud.test.js.map +1 -1
  3. package/dist/__tests__/api/cache-headers.test.d.ts +2 -0
  4. package/dist/__tests__/api/cache-headers.test.d.ts.map +1 -0
  5. package/dist/__tests__/api/cache-headers.test.js +135 -0
  6. package/dist/__tests__/api/cache-headers.test.js.map +1 -0
  7. package/dist/__tests__/api/comments-routes.test.d.ts +2 -0
  8. package/dist/__tests__/api/comments-routes.test.d.ts.map +1 -0
  9. package/dist/__tests__/api/comments-routes.test.js +266 -0
  10. package/dist/__tests__/api/comments-routes.test.js.map +1 -0
  11. package/dist/__tests__/api/notifications-routes.test.d.ts +2 -0
  12. package/dist/__tests__/api/notifications-routes.test.d.ts.map +1 -0
  13. package/dist/__tests__/api/notifications-routes.test.js +225 -0
  14. package/dist/__tests__/api/notifications-routes.test.js.map +1 -0
  15. package/dist/__tests__/api/preview-and-scheduling.test.js +53 -0
  16. package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -1
  17. package/dist/__tests__/fields/relations.test.js +62 -8
  18. package/dist/__tests__/fields/relations.test.js.map +1 -1
  19. package/dist/__tests__/preview-event-bus.test.d.ts +2 -0
  20. package/dist/__tests__/preview-event-bus.test.d.ts.map +1 -0
  21. package/dist/__tests__/preview-event-bus.test.js +89 -0
  22. package/dist/__tests__/preview-event-bus.test.js.map +1 -0
  23. package/dist/__tests__/preview-sse-stream.test.d.ts +2 -0
  24. package/dist/__tests__/preview-sse-stream.test.d.ts.map +1 -0
  25. package/dist/__tests__/preview-sse-stream.test.js +117 -0
  26. package/dist/__tests__/preview-sse-stream.test.js.map +1 -0
  27. package/dist/__tests__/realtime/comments.test.d.ts +2 -0
  28. package/dist/__tests__/realtime/comments.test.d.ts.map +1 -0
  29. package/dist/__tests__/realtime/comments.test.js +508 -0
  30. package/dist/__tests__/realtime/comments.test.js.map +1 -0
  31. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts +2 -0
  32. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts.map +1 -0
  33. package/dist/__tests__/realtime/gateway-ws-integration.test.js +170 -0
  34. package/dist/__tests__/realtime/gateway-ws-integration.test.js.map +1 -0
  35. package/dist/__tests__/realtime/gateway.test.d.ts +2 -0
  36. package/dist/__tests__/realtime/gateway.test.d.ts.map +1 -0
  37. package/dist/__tests__/realtime/gateway.test.js +402 -0
  38. package/dist/__tests__/realtime/gateway.test.js.map +1 -0
  39. package/dist/__tests__/realtime/notifications.test.d.ts +2 -0
  40. package/dist/__tests__/realtime/notifications.test.d.ts.map +1 -0
  41. package/dist/__tests__/realtime/notifications.test.js +317 -0
  42. package/dist/__tests__/realtime/notifications.test.js.map +1 -0
  43. package/dist/__tests__/realtime/persistence.test.d.ts +2 -0
  44. package/dist/__tests__/realtime/persistence.test.d.ts.map +1 -0
  45. package/dist/__tests__/realtime/persistence.test.js +124 -0
  46. package/dist/__tests__/realtime/persistence.test.js.map +1 -0
  47. package/dist/__tests__/schema-preview-features.test.d.ts +2 -0
  48. package/dist/__tests__/schema-preview-features.test.d.ts.map +1 -0
  49. package/dist/__tests__/schema-preview-features.test.js +42 -0
  50. package/dist/__tests__/schema-preview-features.test.js.map +1 -0
  51. package/dist/actions.d.ts.map +1 -1
  52. package/dist/actions.js +71 -26
  53. package/dist/actions.js.map +1 -1
  54. package/dist/api/handlers.d.ts.map +1 -1
  55. package/dist/api/handlers.js +734 -188
  56. package/dist/api/handlers.js.map +1 -1
  57. package/dist/cache/__tests__/http.test.d.ts +2 -0
  58. package/dist/cache/__tests__/http.test.d.ts.map +1 -0
  59. package/dist/cache/__tests__/http.test.js +285 -0
  60. package/dist/cache/__tests__/http.test.js.map +1 -0
  61. package/dist/cache/http.d.ts +119 -0
  62. package/dist/cache/http.d.ts.map +1 -0
  63. package/dist/cache/http.js +188 -0
  64. package/dist/cache/http.js.map +1 -0
  65. package/dist/config/types.d.ts +92 -0
  66. package/dist/config/types.d.ts.map +1 -1
  67. package/dist/fields/relations.d.ts.map +1 -1
  68. package/dist/fields/relations.js +43 -11
  69. package/dist/fields/relations.js.map +1 -1
  70. package/dist/index.d.ts +5 -0
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +2 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/page-builder/schema.d.ts +2 -2
  75. package/dist/preview/event-bus.d.ts +39 -0
  76. package/dist/preview/event-bus.d.ts.map +1 -0
  77. package/dist/preview/event-bus.js +73 -0
  78. package/dist/preview/event-bus.js.map +1 -0
  79. package/dist/preview/sse-stream.d.ts +50 -0
  80. package/dist/preview/sse-stream.d.ts.map +1 -0
  81. package/dist/preview/sse-stream.js +120 -0
  82. package/dist/preview/sse-stream.js.map +1 -0
  83. package/dist/realtime/comments.d.ts +217 -0
  84. package/dist/realtime/comments.d.ts.map +1 -0
  85. package/dist/realtime/comments.js +282 -0
  86. package/dist/realtime/comments.js.map +1 -0
  87. package/dist/realtime/gateway.d.ts +107 -0
  88. package/dist/realtime/gateway.d.ts.map +1 -0
  89. package/dist/realtime/gateway.js +215 -0
  90. package/dist/realtime/gateway.js.map +1 -0
  91. package/dist/realtime/index.d.ts +18 -0
  92. package/dist/realtime/index.d.ts.map +1 -0
  93. package/dist/realtime/index.js +18 -0
  94. package/dist/realtime/index.js.map +1 -0
  95. package/dist/realtime/notifications.d.ts +209 -0
  96. package/dist/realtime/notifications.d.ts.map +1 -0
  97. package/dist/realtime/notifications.js +209 -0
  98. package/dist/realtime/notifications.js.map +1 -0
  99. package/dist/realtime/persistence.d.ts +72 -0
  100. package/dist/realtime/persistence.d.ts.map +1 -0
  101. package/dist/realtime/persistence.js +53 -0
  102. package/dist/realtime/persistence.js.map +1 -0
  103. package/package.json +13 -4
  104. package/prisma/cms-schema.prisma +135 -4
  105. package/prisma/schema.prisma +17 -3
@@ -0,0 +1,120 @@
1
+ import { subscribePreviewUpdates } from './event-bus.js';
2
+ /**
3
+ * SSE wire format helpers. We keep them small and pure so the route handler
4
+ * can build a `ReadableStream<Uint8Array>` without pulling in any extra
5
+ * dependencies (this also keeps the file edge-compatible).
6
+ */
7
+ const encoder = new TextEncoder();
8
+ /**
9
+ * Default keepalive interval. 25 s sits safely under the most aggressive
10
+ * proxy/idle timeouts we've encountered in production (Cloudflare 30 s,
11
+ * Vercel Serverless 30 s, most reverse-proxies 60 s).
12
+ */
13
+ export const PREVIEW_SSE_HEARTBEAT_MS = 25_000;
14
+ /**
15
+ * Maximum stream lifetime in ms. After this, the server closes the stream
16
+ * cleanly so EventSource will reconnect — important because some platforms
17
+ * (Vercel Fluid Compute, Cloudflare Workers) cap any single response at a
18
+ * few minutes regardless of activity.
19
+ */
20
+ export const PREVIEW_SSE_MAX_LIFETIME_MS = 4 * 60 * 1000;
21
+ /**
22
+ * Encode a single SSE message. `event` is optional; when omitted the
23
+ * default `message` event is used.
24
+ */
25
+ export function encodeSseEvent(data, event) {
26
+ const lines = [];
27
+ if (event)
28
+ lines.push(`event: ${event}`);
29
+ const serialized = typeof data === 'string' ? data : JSON.stringify(data);
30
+ for (const line of serialized.split('\n')) {
31
+ lines.push(`data: ${line}`);
32
+ }
33
+ lines.push('');
34
+ lines.push('');
35
+ return lines.join('\n');
36
+ }
37
+ /**
38
+ * Build a `ReadableStream` of Server-Sent Events for the given preview
39
+ * session. The stream:
40
+ *
41
+ * 1. Emits an `event: ready` frame with the session's collection/id.
42
+ * Lets the client confirm the server-side subscription is wired up
43
+ * before it starts treating the absence of events as "stale".
44
+ * 2. Forwards every `notifyPreviewUpdate(collection, id, payload)` to
45
+ * the client as `event: update` with `payload` as JSON data.
46
+ * 3. Sends `: heartbeat\n\n` keepalive comments on
47
+ * `PREVIEW_SSE_HEARTBEAT_MS` intervals.
48
+ * 4. Cleanly closes after `PREVIEW_SSE_MAX_LIFETIME_MS` and lets the
49
+ * browser reconnect, sidestepping platform idle/max-duration caps.
50
+ *
51
+ * The returned stream MUST be wrapped in a `Response` with the SSE
52
+ * headers — `createPreviewStreamResponse` does this for typical callers.
53
+ */
54
+ export function createPreviewStream(session, opts = {}) {
55
+ const heartbeatMs = opts.heartbeatMs ?? PREVIEW_SSE_HEARTBEAT_MS;
56
+ const maxLifetimeMs = opts.maxLifetimeMs ?? PREVIEW_SSE_MAX_LIFETIME_MS;
57
+ let heartbeat;
58
+ let lifetime;
59
+ let unsubscribe;
60
+ return new ReadableStream({
61
+ start(controller) {
62
+ const enqueue = (chunk) => {
63
+ try {
64
+ controller.enqueue(encoder.encode(chunk));
65
+ }
66
+ catch {
67
+ // Controller can be closed mid-emit if the client disconnected
68
+ // between the `notifyPreviewUpdate` call and the enqueue. Swallow.
69
+ }
70
+ };
71
+ enqueue(encodeSseEvent({ collection: session.collection, documentId: session.documentId }, 'ready'));
72
+ unsubscribe = subscribePreviewUpdates(session.collection, session.documentId, (event) => {
73
+ enqueue(encodeSseEvent({
74
+ collection: event.collection,
75
+ documentId: event.documentId,
76
+ at: event.at,
77
+ ...event.payload,
78
+ }, 'update'));
79
+ });
80
+ heartbeat = setInterval(() => {
81
+ enqueue(`: heartbeat\n\n`);
82
+ }, heartbeatMs);
83
+ lifetime = setTimeout(() => {
84
+ try {
85
+ controller.close();
86
+ }
87
+ catch {
88
+ // Already closed; nothing to do.
89
+ }
90
+ }, maxLifetimeMs);
91
+ },
92
+ cancel() {
93
+ if (heartbeat)
94
+ clearInterval(heartbeat);
95
+ if (lifetime)
96
+ clearTimeout(lifetime);
97
+ if (unsubscribe)
98
+ unsubscribe();
99
+ },
100
+ });
101
+ }
102
+ /**
103
+ * Standard SSE response headers. Exposed for callers (and tests) that want
104
+ * to wrap the stream themselves.
105
+ */
106
+ export const PREVIEW_SSE_HEADERS = {
107
+ 'Content-Type': 'text/event-stream; charset=utf-8',
108
+ 'Cache-Control': 'no-cache, no-transform',
109
+ Connection: 'keep-alive',
110
+ // Discourage proxies/CDNs from buffering — without this nginx will hold
111
+ // events back until 4 KB of payload accumulates.
112
+ 'X-Accel-Buffering': 'no',
113
+ };
114
+ export function createPreviewStreamResponse(session, opts) {
115
+ return new Response(createPreviewStream(session, opts), {
116
+ status: 200,
117
+ headers: PREVIEW_SSE_HEADERS,
118
+ });
119
+ }
120
+ //# sourceMappingURL=sse-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse-stream.js","sourceRoot":"","sources":["../../src/preview/sse-stream.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Document comments — anchored, threaded discussion attached to documents.
3
+ *
4
+ * This module owns the *pure* comment logic: validation, normalization, and
5
+ * the storage-agnostic CRUD entry points. The HTTP layer in `api/handlers.ts`
6
+ * is a thin wrapper that handles auth + JSON envelope; everything else lives
7
+ * here so the unit tests don't need a Request loop.
8
+ *
9
+ * Storage shape — see `packages/cms-core/prisma/cms-schema.prisma`,
10
+ * model `DocumentComment`. We require only a minimal Prisma surface so the
11
+ * helpers can be tested against an in-memory fake.
12
+ *
13
+ * Anchors are Yjs *relative positions* serialised as Base64. Storing them in
14
+ * a JSON column (rather than Bytes) keeps the REST contract trivial: the
15
+ * client posts `{ from: "AQ...", to: "AQ..." }`, we round-trip the strings.
16
+ * Slice 5 reconstitutes them with `Y.createAbsolutePositionFromRelativePosition`
17
+ * to paint anchored highlights in the editor; nothing in the API layer needs
18
+ * to interpret the bytes.
19
+ */
20
+ /** Maximum length we accept for a comment body (markdown source). */
21
+ export declare const MAX_COMMENT_BODY_LENGTH = 10000;
22
+ /** Cap on returned comments per `listComments` call. */
23
+ export declare const MAX_COMMENT_LIST_PAGE_SIZE = 200;
24
+ /** Default page size when the caller does not request one. */
25
+ export declare const DEFAULT_COMMENT_LIST_PAGE_SIZE = 100;
26
+ /**
27
+ * Yjs relative-position anchor. The two ends are Base64-encoded
28
+ * `Y.encodeRelativePosition` bytes; slice 5 turns them back into absolute
29
+ * positions for rendering.
30
+ *
31
+ * Slice 4 (this module) treats the anchor as opaque: we only validate that
32
+ * each side is a reasonably short string so we don't store arbitrary blobs.
33
+ */
34
+ export interface CommentAnchor {
35
+ from: string;
36
+ to: string;
37
+ }
38
+ /**
39
+ * Public REST shape of a comment. Mirrors the Prisma row plus a derived
40
+ * `isResolved` flag so the client doesn't have to repeat the null check.
41
+ */
42
+ export interface CommentDTO {
43
+ id: string;
44
+ documentId: string;
45
+ parentId: string | null;
46
+ userId: string | null;
47
+ body: string;
48
+ anchor: CommentAnchor | null;
49
+ resolvedAt: string | null;
50
+ resolvedById: string | null;
51
+ deletedAt: string | null;
52
+ createdAt: string;
53
+ updatedAt: string;
54
+ isResolved: boolean;
55
+ }
56
+ /** Input for creating a new comment. */
57
+ export interface CreateCommentInput {
58
+ documentId: string;
59
+ userId: string;
60
+ body: string;
61
+ anchor?: CommentAnchor | null;
62
+ parentId?: string | null;
63
+ }
64
+ /** Input for updating an existing comment. Only the body is editable. */
65
+ export interface UpdateCommentInput {
66
+ body: string;
67
+ }
68
+ /** Filter passed to `listComments`. */
69
+ export interface ListCommentsFilter {
70
+ documentId: string;
71
+ /** Include resolved threads. Default: `false`. */
72
+ includeResolved?: boolean;
73
+ /** Include soft-deleted comments. Default: `false`. */
74
+ includeDeleted?: boolean;
75
+ /** Page size (1..`MAX_COMMENT_LIST_PAGE_SIZE`). */
76
+ pageSize?: number;
77
+ }
78
+ /**
79
+ * Minimal Prisma surface we depend on. The real `PrismaClient` satisfies it,
80
+ * but tests inject a small in-memory fake.
81
+ */
82
+ export interface CommentsDB {
83
+ documentComment: {
84
+ findUnique: (args: {
85
+ where: {
86
+ id: string;
87
+ };
88
+ [key: string]: unknown;
89
+ }) => Promise<DocumentCommentRow | null>;
90
+ findMany: (args: {
91
+ where: Record<string, unknown>;
92
+ orderBy?: Record<string, 'asc' | 'desc'>;
93
+ take?: number;
94
+ }) => Promise<DocumentCommentRow[]>;
95
+ create: (args: {
96
+ data: {
97
+ documentId: string;
98
+ userId: string | null;
99
+ body: string;
100
+ anchor: CommentAnchor | null;
101
+ parentId: string | null;
102
+ };
103
+ }) => Promise<DocumentCommentRow>;
104
+ update: (args: {
105
+ where: {
106
+ id: string;
107
+ };
108
+ data: Partial<{
109
+ body: string;
110
+ resolvedAt: Date | null;
111
+ resolvedById: string | null;
112
+ deletedAt: Date | null;
113
+ }>;
114
+ }) => Promise<DocumentCommentRow>;
115
+ };
116
+ }
117
+ /** Row shape returned by Prisma. We accept Date or ISO strings so fakes are cheap. */
118
+ export interface DocumentCommentRow {
119
+ id: string;
120
+ documentId: string;
121
+ parentId: string | null;
122
+ userId: string | null;
123
+ body: string;
124
+ anchor: CommentAnchor | null;
125
+ resolvedAt: Date | string | null;
126
+ resolvedById: string | null;
127
+ deletedAt: Date | string | null;
128
+ createdAt: Date | string;
129
+ updatedAt: Date | string;
130
+ }
131
+ /** Discriminated result type used by the service entry points. */
132
+ export type CommentsResult<T> = {
133
+ ok: true;
134
+ value: T;
135
+ } | {
136
+ ok: false;
137
+ error: CommentsError;
138
+ };
139
+ export interface CommentsError {
140
+ /**
141
+ * Stable machine-readable code. The HTTP layer maps each to a status:
142
+ * - validation → 400
143
+ * - not_found → 404
144
+ * - forbidden → 403
145
+ * - conflict → 409 (e.g. resolving an already-resolved thread)
146
+ */
147
+ code: 'validation' | 'not_found' | 'forbidden' | 'conflict';
148
+ message: string;
149
+ }
150
+ /**
151
+ * Strip a comment row down to the public DTO. Centralised so the response
152
+ * shape stays consistent between create / list / update / resolve.
153
+ */
154
+ export declare function toCommentDTO(row: DocumentCommentRow): CommentDTO;
155
+ /**
156
+ * Parse + validate an anchor blob coming from an untrusted client. Returns
157
+ * `null` when the caller did not provide one (document-level comments).
158
+ */
159
+ export declare function parseAnchor(input: unknown): CommentsResult<CommentAnchor | null>;
160
+ /** Normalize whitespace + enforce length limits on a posted body. */
161
+ export declare function normaliseBody(input: unknown): CommentsResult<string>;
162
+ /**
163
+ * Create a comment. Enforces:
164
+ * - body validation (non-empty, length-capped)
165
+ * - anchor shape validation
166
+ * - single-level threading: a `parentId` must reference an existing,
167
+ * non-deleted, non-reply comment under the same document.
168
+ *
169
+ * The caller (HTTP handler) is responsible for auth — once we reach here we
170
+ * trust the supplied `userId`.
171
+ */
172
+ export declare function createComment(db: CommentsDB, input: CreateCommentInput): Promise<CommentsResult<CommentDTO>>;
173
+ /**
174
+ * List comments for a document. Returns the rows ordered oldest-first so a
175
+ * naive client can render threads top-down. Pagination is "show the first
176
+ * N"; the higher-level UI in slice 5 will switch to cursor pagination once
177
+ * volume justifies it.
178
+ */
179
+ export declare function listComments(db: CommentsDB, filter: ListCommentsFilter): Promise<CommentsResult<CommentDTO[]>>;
180
+ /**
181
+ * Edit a comment body. Only the author can edit, and only if the comment is
182
+ * not soft-deleted. Resolved threads remain editable so a moderator can
183
+ * append context.
184
+ */
185
+ export declare function updateComment(db: CommentsDB, id: string, input: UpdateCommentInput, actor: {
186
+ userId: string;
187
+ isAdmin: boolean;
188
+ }): Promise<CommentsResult<CommentDTO>>;
189
+ /**
190
+ * Mark a comment thread as resolved. Either the comment's author or any
191
+ * admin / write-role caller can resolve. Resolving an already-resolved
192
+ * comment is a no-op (returns the existing row) so a duplicate click in the
193
+ * UI does not surface as an error.
194
+ */
195
+ export declare function resolveComment(db: CommentsDB, id: string, actor: {
196
+ userId: string;
197
+ canResolve: boolean;
198
+ }): Promise<CommentsResult<CommentDTO>>;
199
+ /**
200
+ * Re-open a resolved thread. Same permission as `resolveComment`.
201
+ */
202
+ export declare function reopenComment(db: CommentsDB, id: string, actor: {
203
+ userId: string;
204
+ canResolve: boolean;
205
+ }): Promise<CommentsResult<CommentDTO>>;
206
+ /**
207
+ * Soft-delete a comment. The row is preserved (so replies don't lose their
208
+ * thread root) but stops being returned by `listComments` unless explicitly
209
+ * requested with `includeDeleted: true`.
210
+ *
211
+ * Only the author or an admin may delete.
212
+ */
213
+ export declare function deleteComment(db: CommentsDB, id: string, actor: {
214
+ userId: string;
215
+ isAdmin: boolean;
216
+ }): Promise<CommentsResult<CommentDTO>>;
217
+ //# sourceMappingURL=comments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comments.d.ts","sourceRoot":"","sources":["../../src/realtime/comments.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Document comments — anchored, threaded discussion attached to documents.
3
+ *
4
+ * This module owns the *pure* comment logic: validation, normalization, and
5
+ * the storage-agnostic CRUD entry points. The HTTP layer in `api/handlers.ts`
6
+ * is a thin wrapper that handles auth + JSON envelope; everything else lives
7
+ * here so the unit tests don't need a Request loop.
8
+ *
9
+ * Storage shape — see `packages/cms-core/prisma/cms-schema.prisma`,
10
+ * model `DocumentComment`. We require only a minimal Prisma surface so the
11
+ * helpers can be tested against an in-memory fake.
12
+ *
13
+ * Anchors are Yjs *relative positions* serialised as Base64. Storing them in
14
+ * a JSON column (rather than Bytes) keeps the REST contract trivial: the
15
+ * client posts `{ from: "AQ...", to: "AQ..." }`, we round-trip the strings.
16
+ * Slice 5 reconstitutes them with `Y.createAbsolutePositionFromRelativePosition`
17
+ * to paint anchored highlights in the editor; nothing in the API layer needs
18
+ * to interpret the bytes.
19
+ */
20
+ /** Maximum length we accept for a comment body (markdown source). */
21
+ export const MAX_COMMENT_BODY_LENGTH = 10_000;
22
+ /** Cap on returned comments per `listComments` call. */
23
+ export const MAX_COMMENT_LIST_PAGE_SIZE = 200;
24
+ /** Default page size when the caller does not request one. */
25
+ export const DEFAULT_COMMENT_LIST_PAGE_SIZE = 100;
26
+ const MAX_ANCHOR_FIELD_LENGTH = 4_096;
27
+ function err(code, message) {
28
+ return { code, message };
29
+ }
30
+ function toIso(value) {
31
+ if (value === null)
32
+ return null;
33
+ if (value instanceof Date)
34
+ return value.toISOString();
35
+ // Tolerate fakes that already store ISO strings.
36
+ return value;
37
+ }
38
+ function toIsoRequired(value) {
39
+ return value instanceof Date ? value.toISOString() : value;
40
+ }
41
+ /**
42
+ * Strip a comment row down to the public DTO. Centralised so the response
43
+ * shape stays consistent between create / list / update / resolve.
44
+ */
45
+ export function toCommentDTO(row) {
46
+ return {
47
+ id: row.id,
48
+ documentId: row.documentId,
49
+ parentId: row.parentId,
50
+ userId: row.userId,
51
+ body: row.body,
52
+ anchor: row.anchor,
53
+ resolvedAt: toIso(row.resolvedAt),
54
+ resolvedById: row.resolvedById,
55
+ deletedAt: toIso(row.deletedAt),
56
+ createdAt: toIsoRequired(row.createdAt),
57
+ updatedAt: toIsoRequired(row.updatedAt),
58
+ isResolved: row.resolvedAt !== null,
59
+ };
60
+ }
61
+ /**
62
+ * Parse + validate an anchor blob coming from an untrusted client. Returns
63
+ * `null` when the caller did not provide one (document-level comments).
64
+ */
65
+ export function parseAnchor(input) {
66
+ if (input === undefined || input === null) {
67
+ return { ok: true, value: null };
68
+ }
69
+ if (typeof input !== 'object' || Array.isArray(input)) {
70
+ return { ok: false, error: err('validation', 'anchor must be an object with `from` and `to`') };
71
+ }
72
+ const obj = input;
73
+ if (typeof obj.from !== 'string' || typeof obj.to !== 'string') {
74
+ return { ok: false, error: err('validation', 'anchor.from and anchor.to must be strings') };
75
+ }
76
+ if (obj.from.length === 0 ||
77
+ obj.from.length > MAX_ANCHOR_FIELD_LENGTH ||
78
+ obj.to.length === 0 ||
79
+ obj.to.length > MAX_ANCHOR_FIELD_LENGTH) {
80
+ return {
81
+ ok: false,
82
+ error: err('validation', `anchor fields must be 1..${MAX_ANCHOR_FIELD_LENGTH} chars`),
83
+ };
84
+ }
85
+ return { ok: true, value: { from: obj.from, to: obj.to } };
86
+ }
87
+ /** Normalize whitespace + enforce length limits on a posted body. */
88
+ export function normaliseBody(input) {
89
+ if (typeof input !== 'string') {
90
+ return { ok: false, error: err('validation', 'body must be a string') };
91
+ }
92
+ const trimmed = input.trim();
93
+ if (trimmed.length === 0) {
94
+ return { ok: false, error: err('validation', 'body cannot be empty') };
95
+ }
96
+ if (trimmed.length > MAX_COMMENT_BODY_LENGTH) {
97
+ return {
98
+ ok: false,
99
+ error: err('validation', `body cannot exceed ${MAX_COMMENT_BODY_LENGTH} characters`),
100
+ };
101
+ }
102
+ return { ok: true, value: trimmed };
103
+ }
104
+ /**
105
+ * Create a comment. Enforces:
106
+ * - body validation (non-empty, length-capped)
107
+ * - anchor shape validation
108
+ * - single-level threading: a `parentId` must reference an existing,
109
+ * non-deleted, non-reply comment under the same document.
110
+ *
111
+ * The caller (HTTP handler) is responsible for auth — once we reach here we
112
+ * trust the supplied `userId`.
113
+ */
114
+ export async function createComment(db, input) {
115
+ if (typeof input.documentId !== 'string' || input.documentId.length === 0) {
116
+ return { ok: false, error: err('validation', 'documentId is required') };
117
+ }
118
+ if (typeof input.userId !== 'string' || input.userId.length === 0) {
119
+ return { ok: false, error: err('validation', 'userId is required') };
120
+ }
121
+ const body = normaliseBody(input.body);
122
+ if (!body.ok)
123
+ return body;
124
+ const anchor = parseAnchor(input.anchor);
125
+ if (!anchor.ok)
126
+ return anchor;
127
+ let parentId = null;
128
+ if (input.parentId != null && input.parentId !== '') {
129
+ if (typeof input.parentId !== 'string') {
130
+ return { ok: false, error: err('validation', 'parentId must be a string') };
131
+ }
132
+ const parent = await db.documentComment.findUnique({ where: { id: input.parentId } });
133
+ if (!parent) {
134
+ return { ok: false, error: err('not_found', 'parent comment not found') };
135
+ }
136
+ if (parent.documentId !== input.documentId) {
137
+ return { ok: false, error: err('validation', 'parent comment is on a different document') };
138
+ }
139
+ if (parent.parentId !== null) {
140
+ return {
141
+ ok: false,
142
+ error: err('validation', 'replies cannot themselves be replied to (one level only)'),
143
+ };
144
+ }
145
+ if (parent.deletedAt !== null) {
146
+ return { ok: false, error: err('not_found', 'parent comment has been deleted') };
147
+ }
148
+ parentId = parent.id;
149
+ }
150
+ const row = await db.documentComment.create({
151
+ data: {
152
+ documentId: input.documentId,
153
+ userId: input.userId,
154
+ body: body.value,
155
+ anchor: anchor.value,
156
+ parentId,
157
+ },
158
+ });
159
+ return { ok: true, value: toCommentDTO(row) };
160
+ }
161
+ /**
162
+ * List comments for a document. Returns the rows ordered oldest-first so a
163
+ * naive client can render threads top-down. Pagination is "show the first
164
+ * N"; the higher-level UI in slice 5 will switch to cursor pagination once
165
+ * volume justifies it.
166
+ */
167
+ export async function listComments(db, filter) {
168
+ if (typeof filter.documentId !== 'string' || filter.documentId.length === 0) {
169
+ return { ok: false, error: err('validation', 'documentId is required') };
170
+ }
171
+ const where = { documentId: filter.documentId };
172
+ if (!filter.includeResolved)
173
+ where.resolvedAt = null;
174
+ if (!filter.includeDeleted)
175
+ where.deletedAt = null;
176
+ const pageSize = clampPageSize(filter.pageSize);
177
+ const rows = await db.documentComment.findMany({
178
+ where,
179
+ orderBy: { createdAt: 'asc' },
180
+ take: pageSize,
181
+ });
182
+ return { ok: true, value: rows.map(toCommentDTO) };
183
+ }
184
+ function clampPageSize(n) {
185
+ if (n === undefined || Number.isNaN(n))
186
+ return DEFAULT_COMMENT_LIST_PAGE_SIZE;
187
+ if (n < 1)
188
+ return 1;
189
+ if (n > MAX_COMMENT_LIST_PAGE_SIZE)
190
+ return MAX_COMMENT_LIST_PAGE_SIZE;
191
+ return Math.floor(n);
192
+ }
193
+ /**
194
+ * Edit a comment body. Only the author can edit, and only if the comment is
195
+ * not soft-deleted. Resolved threads remain editable so a moderator can
196
+ * append context.
197
+ */
198
+ export async function updateComment(db, id, input, actor) {
199
+ const existing = await db.documentComment.findUnique({ where: { id } });
200
+ if (!existing || existing.deletedAt !== null) {
201
+ return { ok: false, error: err('not_found', 'comment not found') };
202
+ }
203
+ if (!actor.isAdmin && existing.userId !== actor.userId) {
204
+ return { ok: false, error: err('forbidden', 'only the author can edit this comment') };
205
+ }
206
+ const body = normaliseBody(input.body);
207
+ if (!body.ok)
208
+ return body;
209
+ const row = await db.documentComment.update({
210
+ where: { id },
211
+ data: { body: body.value },
212
+ });
213
+ return { ok: true, value: toCommentDTO(row) };
214
+ }
215
+ /**
216
+ * Mark a comment thread as resolved. Either the comment's author or any
217
+ * admin / write-role caller can resolve. Resolving an already-resolved
218
+ * comment is a no-op (returns the existing row) so a duplicate click in the
219
+ * UI does not surface as an error.
220
+ */
221
+ export async function resolveComment(db, id, actor) {
222
+ const existing = await db.documentComment.findUnique({ where: { id } });
223
+ if (!existing || existing.deletedAt !== null) {
224
+ return { ok: false, error: err('not_found', 'comment not found') };
225
+ }
226
+ if (!actor.canResolve && existing.userId !== actor.userId) {
227
+ return { ok: false, error: err('forbidden', 'not allowed to resolve this comment') };
228
+ }
229
+ if (existing.resolvedAt !== null) {
230
+ return { ok: true, value: toCommentDTO(existing) };
231
+ }
232
+ const row = await db.documentComment.update({
233
+ where: { id },
234
+ data: { resolvedAt: new Date(), resolvedById: actor.userId },
235
+ });
236
+ return { ok: true, value: toCommentDTO(row) };
237
+ }
238
+ /**
239
+ * Re-open a resolved thread. Same permission as `resolveComment`.
240
+ */
241
+ export async function reopenComment(db, id, actor) {
242
+ const existing = await db.documentComment.findUnique({ where: { id } });
243
+ if (!existing || existing.deletedAt !== null) {
244
+ return { ok: false, error: err('not_found', 'comment not found') };
245
+ }
246
+ if (!actor.canResolve && existing.userId !== actor.userId) {
247
+ return { ok: false, error: err('forbidden', 'not allowed to re-open this comment') };
248
+ }
249
+ if (existing.resolvedAt === null) {
250
+ return { ok: true, value: toCommentDTO(existing) };
251
+ }
252
+ const row = await db.documentComment.update({
253
+ where: { id },
254
+ data: { resolvedAt: null, resolvedById: null },
255
+ });
256
+ return { ok: true, value: toCommentDTO(row) };
257
+ }
258
+ /**
259
+ * Soft-delete a comment. The row is preserved (so replies don't lose their
260
+ * thread root) but stops being returned by `listComments` unless explicitly
261
+ * requested with `includeDeleted: true`.
262
+ *
263
+ * Only the author or an admin may delete.
264
+ */
265
+ export async function deleteComment(db, id, actor) {
266
+ const existing = await db.documentComment.findUnique({ where: { id } });
267
+ if (!existing) {
268
+ return { ok: false, error: err('not_found', 'comment not found') };
269
+ }
270
+ if (!actor.isAdmin && existing.userId !== actor.userId) {
271
+ return { ok: false, error: err('forbidden', 'only the author can delete this comment') };
272
+ }
273
+ if (existing.deletedAt !== null) {
274
+ return { ok: true, value: toCommentDTO(existing) };
275
+ }
276
+ const row = await db.documentComment.update({
277
+ where: { id },
278
+ data: { deletedAt: new Date() },
279
+ });
280
+ return { ok: true, value: toCommentDTO(row) };
281
+ }
282
+ //# sourceMappingURL=comments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comments.js","sourceRoot":"","sources":["../../src/realtime/comments.ts"],"names":[],"mappings":"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"}