@actuate-media/cms-core 0.20.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 (150) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/actions/document-crud.test.js +74 -2
  3. package/dist/__tests__/actions/document-crud.test.js.map +1 -1
  4. package/dist/__tests__/api/cache-headers.test.d.ts +2 -0
  5. package/dist/__tests__/api/cache-headers.test.d.ts.map +1 -0
  6. package/dist/__tests__/api/cache-headers.test.js +135 -0
  7. package/dist/__tests__/api/cache-headers.test.js.map +1 -0
  8. package/dist/__tests__/api/comments-routes.test.d.ts +2 -0
  9. package/dist/__tests__/api/comments-routes.test.d.ts.map +1 -0
  10. package/dist/__tests__/api/comments-routes.test.js +266 -0
  11. package/dist/__tests__/api/comments-routes.test.js.map +1 -0
  12. package/dist/__tests__/api/notifications-routes.test.d.ts +2 -0
  13. package/dist/__tests__/api/notifications-routes.test.d.ts.map +1 -0
  14. package/dist/__tests__/api/notifications-routes.test.js +225 -0
  15. package/dist/__tests__/api/notifications-routes.test.js.map +1 -0
  16. package/dist/__tests__/api/preview-and-scheduling.test.js +53 -0
  17. package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -1
  18. package/dist/__tests__/fields/component-block.test.d.ts +2 -0
  19. package/dist/__tests__/fields/component-block.test.d.ts.map +1 -0
  20. package/dist/__tests__/fields/component-block.test.js +129 -0
  21. package/dist/__tests__/fields/component-block.test.js.map +1 -0
  22. package/dist/__tests__/fields/relations.test.js +62 -8
  23. package/dist/__tests__/fields/relations.test.js.map +1 -1
  24. package/dist/__tests__/preview-event-bus.test.d.ts +2 -0
  25. package/dist/__tests__/preview-event-bus.test.d.ts.map +1 -0
  26. package/dist/__tests__/preview-event-bus.test.js +89 -0
  27. package/dist/__tests__/preview-event-bus.test.js.map +1 -0
  28. package/dist/__tests__/preview-sse-stream.test.d.ts +2 -0
  29. package/dist/__tests__/preview-sse-stream.test.d.ts.map +1 -0
  30. package/dist/__tests__/preview-sse-stream.test.js +117 -0
  31. package/dist/__tests__/preview-sse-stream.test.js.map +1 -0
  32. package/dist/__tests__/realtime/comments.test.d.ts +2 -0
  33. package/dist/__tests__/realtime/comments.test.d.ts.map +1 -0
  34. package/dist/__tests__/realtime/comments.test.js +508 -0
  35. package/dist/__tests__/realtime/comments.test.js.map +1 -0
  36. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts +2 -0
  37. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts.map +1 -0
  38. package/dist/__tests__/realtime/gateway-ws-integration.test.js +170 -0
  39. package/dist/__tests__/realtime/gateway-ws-integration.test.js.map +1 -0
  40. package/dist/__tests__/realtime/gateway.test.d.ts +2 -0
  41. package/dist/__tests__/realtime/gateway.test.d.ts.map +1 -0
  42. package/dist/__tests__/realtime/gateway.test.js +402 -0
  43. package/dist/__tests__/realtime/gateway.test.js.map +1 -0
  44. package/dist/__tests__/realtime/notifications.test.d.ts +2 -0
  45. package/dist/__tests__/realtime/notifications.test.d.ts.map +1 -0
  46. package/dist/__tests__/realtime/notifications.test.js +317 -0
  47. package/dist/__tests__/realtime/notifications.test.js.map +1 -0
  48. package/dist/__tests__/realtime/persistence.test.d.ts +2 -0
  49. package/dist/__tests__/realtime/persistence.test.d.ts.map +1 -0
  50. package/dist/__tests__/realtime/persistence.test.js +124 -0
  51. package/dist/__tests__/realtime/persistence.test.js.map +1 -0
  52. package/dist/__tests__/schema-preview-features.test.d.ts +2 -0
  53. package/dist/__tests__/schema-preview-features.test.d.ts.map +1 -0
  54. package/dist/__tests__/schema-preview-features.test.js +42 -0
  55. package/dist/__tests__/schema-preview-features.test.js.map +1 -0
  56. package/dist/actions.d.ts.map +1 -1
  57. package/dist/actions.js +71 -26
  58. package/dist/actions.js.map +1 -1
  59. package/dist/api/handlers.d.ts.map +1 -1
  60. package/dist/api/handlers.js +734 -188
  61. package/dist/api/handlers.js.map +1 -1
  62. package/dist/cache/__tests__/http.test.d.ts +2 -0
  63. package/dist/cache/__tests__/http.test.d.ts.map +1 -0
  64. package/dist/cache/__tests__/http.test.js +285 -0
  65. package/dist/cache/__tests__/http.test.js.map +1 -0
  66. package/dist/cache/http.d.ts +119 -0
  67. package/dist/cache/http.d.ts.map +1 -0
  68. package/dist/cache/http.js +188 -0
  69. package/dist/cache/http.js.map +1 -0
  70. package/dist/config/types.d.ts +153 -1
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/fields/component-block.d.ts +23 -0
  73. package/dist/fields/component-block.d.ts.map +1 -0
  74. package/dist/fields/component-block.js +137 -0
  75. package/dist/fields/component-block.js.map +1 -0
  76. package/dist/fields/index.d.ts.map +1 -1
  77. package/dist/fields/index.js +2 -0
  78. package/dist/fields/index.js.map +1 -1
  79. package/dist/fields/relations.d.ts.map +1 -1
  80. package/dist/fields/relations.js +43 -11
  81. package/dist/fields/relations.js.map +1 -1
  82. package/dist/index.d.ts +5 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -0
  85. package/dist/index.js.map +1 -1
  86. package/dist/page-builder/schema.d.ts +2 -2
  87. package/dist/preview/event-bus.d.ts +39 -0
  88. package/dist/preview/event-bus.d.ts.map +1 -0
  89. package/dist/preview/event-bus.js +73 -0
  90. package/dist/preview/event-bus.js.map +1 -0
  91. package/dist/preview/sse-stream.d.ts +50 -0
  92. package/dist/preview/sse-stream.d.ts.map +1 -0
  93. package/dist/preview/sse-stream.js +120 -0
  94. package/dist/preview/sse-stream.js.map +1 -0
  95. package/dist/realtime/comments.d.ts +217 -0
  96. package/dist/realtime/comments.d.ts.map +1 -0
  97. package/dist/realtime/comments.js +282 -0
  98. package/dist/realtime/comments.js.map +1 -0
  99. package/dist/realtime/gateway.d.ts +107 -0
  100. package/dist/realtime/gateway.d.ts.map +1 -0
  101. package/dist/realtime/gateway.js +215 -0
  102. package/dist/realtime/gateway.js.map +1 -0
  103. package/dist/realtime/index.d.ts +18 -0
  104. package/dist/realtime/index.d.ts.map +1 -0
  105. package/dist/realtime/index.js +18 -0
  106. package/dist/realtime/index.js.map +1 -0
  107. package/dist/realtime/notifications.d.ts +209 -0
  108. package/dist/realtime/notifications.d.ts.map +1 -0
  109. package/dist/realtime/notifications.js +209 -0
  110. package/dist/realtime/notifications.js.map +1 -0
  111. package/dist/realtime/persistence.d.ts +72 -0
  112. package/dist/realtime/persistence.d.ts.map +1 -0
  113. package/dist/realtime/persistence.js +53 -0
  114. package/dist/realtime/persistence.js.map +1 -0
  115. package/package.json +13 -4
  116. package/prisma/cms-schema.prisma +437 -306
  117. package/prisma/migrations/0001_init/migration.sql +384 -384
  118. package/prisma/migrations/0002_folders/migration.sql +39 -39
  119. package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -50
  120. package/prisma/migrations/0004_script_tags/migration.sql +21 -21
  121. package/prisma/migrations/0005_password_reset_tokens/migration.sql +20 -20
  122. package/prisma/migrations/0006_page_builder/migration.sql +38 -38
  123. package/prisma/migrations/migration_lock.toml +3 -3
  124. package/prisma/schema.prisma +563 -549
  125. package/generated/browser.ts +0 -109
  126. package/generated/client.ts +0 -133
  127. package/generated/commonInputTypes.ts +0 -709
  128. package/generated/enums.ts +0 -125
  129. package/generated/internal/class.ts +0 -376
  130. package/generated/internal/prismaNamespace.ts +0 -2617
  131. package/generated/internal/prismaNamespaceBrowser.ts +0 -611
  132. package/generated/models/ApiKey.ts +0 -1550
  133. package/generated/models/AuditLog.ts +0 -1206
  134. package/generated/models/BackupRecord.ts +0 -1250
  135. package/generated/models/ContentLock.ts +0 -1472
  136. package/generated/models/ContentTemplate.ts +0 -1416
  137. package/generated/models/Document.ts +0 -3005
  138. package/generated/models/Folder.ts +0 -1904
  139. package/generated/models/FormSubmission.ts +0 -1200
  140. package/generated/models/InAppNotification.ts +0 -1457
  141. package/generated/models/Media.ts +0 -2340
  142. package/generated/models/MediaUsage.ts +0 -1472
  143. package/generated/models/OAuthAccount.ts +0 -1463
  144. package/generated/models/Redirect.ts +0 -1284
  145. package/generated/models/Session.ts +0 -1492
  146. package/generated/models/Site.ts +0 -1206
  147. package/generated/models/User.ts +0 -3513
  148. package/generated/models/Version.ts +0 -1511
  149. package/generated/models/WorkflowState.ts +0 -1514
  150. package/generated/models.ts +0 -29
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Realtime collaboration surface for `@actuate-media/cms-core`.
3
+ *
4
+ * - `createRealtimeGateway` — transport-agnostic gateway that owns rooms,
5
+ * debounces persistence, and reaps idle documents. Consumers wrap their
6
+ * server's socket primitive in a `WebSocketLike` adapter and feed it via
7
+ * `gateway.handleConnection(socket, req)`.
8
+ * - `createPrismaDocumentPersistence` — Prisma-backed implementation of
9
+ * `DocumentPersistence` against the `DocumentCRDT` table.
10
+ *
11
+ * See `packages/realtime/README.md` for the protocol details and
12
+ * `docs/roadmap-status.md` for the Phase 3 slice plan.
13
+ */
14
+ export { createRealtimeGateway, } from './gateway.js';
15
+ export { createPrismaDocumentPersistence, } from './persistence.js';
16
+ export { createComment, deleteComment, listComments, normaliseBody, parseAnchor, reopenComment, resolveComment, toCommentDTO, updateComment, DEFAULT_COMMENT_LIST_PAGE_SIZE, MAX_COMMENT_BODY_LENGTH, MAX_COMMENT_LIST_PAGE_SIZE, } from './comments.js';
17
+ export { createNotification, excerptForNotification, extractMentionedUserIds, isNotificationKind, listForUser as listNotificationsForUser, markAllRead as markAllNotificationsRead, markRead as markNotificationRead, toNotificationDTO, unreadCount as notificationsUnreadCount, DEFAULT_NOTIFICATION_LIST_PAGE_SIZE, MAX_NOTIFICATION_LIST_PAGE_SIZE, } from './notifications.js';
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/realtime/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EACL,qBAAqB,GAMtB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,+BAA+B,GAGhC,MAAM,kBAAkB,CAAA;AAEzB,OAAO,EACL,aAAa,EACb,aAAa,EACb,YAAY,EACZ,aAAa,EACb,WAAW,EACX,aAAa,EACb,cAAc,EACd,YAAY,EACZ,aAAa,EACb,8BAA8B,EAC9B,uBAAuB,EACvB,0BAA0B,GAU3B,MAAM,eAAe,CAAA;AAEtB,OAAO,EACL,kBAAkB,EAClB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,EAClB,WAAW,IAAI,wBAAwB,EACvC,WAAW,IAAI,wBAAwB,EACvC,QAAQ,IAAI,oBAAoB,EAChC,iBAAiB,EACjB,WAAW,IAAI,wBAAwB,EACvC,mCAAmC,EACnC,+BAA+B,GAUhC,MAAM,oBAAoB,CAAA"}
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Per-user notifications driven by collaboration events.
3
+ *
4
+ * Owns the pure notification logic: kind discriminator, payload validation,
5
+ * permission gates, and a storage-agnostic CRUD surface. The HTTP layer
6
+ * (in `api/handlers.ts`) is a thin wrapper that handles auth + JSON
7
+ * envelope; the comments hook layer (slice 6, `notification-triggers.ts`)
8
+ * calls these helpers when a comment lifecycle event fires.
9
+ *
10
+ * Storage shape — see `packages/cms-core/prisma/cms-schema.prisma`, model
11
+ * `DocumentNotification`. We require only a minimal Prisma surface so the
12
+ * helpers can be tested against an in-memory fake.
13
+ *
14
+ * ## Discriminated payloads
15
+ *
16
+ * The `payload` Json column varies by `kind`. The TypeScript types here
17
+ * are the source of truth for each variant; the service helpers narrow
18
+ * by `kind` so callers can't accidentally insert a malformed payload.
19
+ *
20
+ * ## Mentions
21
+ *
22
+ * Slice 6 ships a deliberately simple mention parser: `@<userId>` tokens
23
+ * in a comment body trigger a `comment_mention` notification. A richer
24
+ * `@username` parser that resolves against the user table is a Phase 4
25
+ * follow-up — the storage shape already supports it.
26
+ */
27
+ /** Hard cap on returned notifications per `listForUser` call. */
28
+ export declare const MAX_NOTIFICATION_LIST_PAGE_SIZE = 200;
29
+ /** Default page size when the caller does not request one. */
30
+ export declare const DEFAULT_NOTIFICATION_LIST_PAGE_SIZE = 50;
31
+ /**
32
+ * Notification kind discriminator. New variants extend the `Payload`
33
+ * union — never replace existing kinds, because old rows live forever
34
+ * in the table.
35
+ */
36
+ export type NotificationKind = 'comment_reply' | 'comment_resolved' | 'comment_mention';
37
+ export interface CommentReplyPayload {
38
+ /** User who replied. */
39
+ actorId: string | null;
40
+ actorName: string | null;
41
+ /** First 120 chars of the reply body, for the bell preview. */
42
+ excerpt: string;
43
+ }
44
+ export interface CommentResolvedPayload {
45
+ actorId: string | null;
46
+ actorName: string | null;
47
+ /** Excerpt of the resolved root comment, so the bell can show context. */
48
+ rootExcerpt: string;
49
+ }
50
+ export interface CommentMentionPayload {
51
+ actorId: string | null;
52
+ actorName: string | null;
53
+ /** Excerpt of the body that mentioned the recipient. */
54
+ excerpt: string;
55
+ }
56
+ export type NotificationPayload = ({
57
+ kind: 'comment_reply';
58
+ } & CommentReplyPayload) | ({
59
+ kind: 'comment_resolved';
60
+ } & CommentResolvedPayload) | ({
61
+ kind: 'comment_mention';
62
+ } & CommentMentionPayload);
63
+ /** Public REST shape. Mirrors the row plus the discriminated payload. */
64
+ export interface NotificationDTO {
65
+ id: string;
66
+ userId: string;
67
+ kind: NotificationKind;
68
+ documentId: string | null;
69
+ commentId: string | null;
70
+ payload: NotificationPayload;
71
+ readAt: string | null;
72
+ createdAt: string;
73
+ }
74
+ export interface CreateNotificationInput {
75
+ userId: string;
76
+ documentId?: string | null;
77
+ commentId?: string | null;
78
+ payload: NotificationPayload;
79
+ }
80
+ export interface ListNotificationsFilter {
81
+ userId: string;
82
+ /** Default: false. When true, returns read + unread notifications. */
83
+ includeRead?: boolean;
84
+ /** Default: DEFAULT_NOTIFICATION_LIST_PAGE_SIZE. Hard upper bound applied. */
85
+ pageSize?: number;
86
+ }
87
+ export type NotificationsError = {
88
+ code: 'validation';
89
+ message: string;
90
+ } | {
91
+ code: 'forbidden';
92
+ message: string;
93
+ } | {
94
+ code: 'not_found';
95
+ message: string;
96
+ };
97
+ export type NotificationsResult<T> = {
98
+ ok: true;
99
+ value: T;
100
+ } | {
101
+ ok: false;
102
+ error: NotificationsError;
103
+ };
104
+ /**
105
+ * Minimal subset of Prisma's notification delegate we depend on. Keeping
106
+ * this narrow lets the unit tests use a fake implementation that holds
107
+ * rows in an array — no schema generation required.
108
+ */
109
+ export interface DocumentNotificationRow {
110
+ id: string;
111
+ userId: string;
112
+ kind: string;
113
+ documentId: string | null;
114
+ commentId: string | null;
115
+ payload: unknown;
116
+ readAt: Date | null;
117
+ createdAt: Date;
118
+ }
119
+ export interface NotificationsDB {
120
+ documentNotification: {
121
+ create(args: {
122
+ data: Omit<DocumentNotificationRow, 'id' | 'createdAt' | 'readAt'> & {
123
+ readAt?: Date | null;
124
+ };
125
+ }): Promise<DocumentNotificationRow>;
126
+ findUnique(args: {
127
+ where: {
128
+ id: string;
129
+ };
130
+ }): Promise<DocumentNotificationRow | null>;
131
+ findMany(args: {
132
+ where: {
133
+ userId: string;
134
+ readAt?: null | {
135
+ not: null;
136
+ };
137
+ };
138
+ orderBy?: {
139
+ createdAt: 'desc' | 'asc';
140
+ };
141
+ take?: number;
142
+ }): Promise<DocumentNotificationRow[]>;
143
+ update(args: {
144
+ where: {
145
+ id: string;
146
+ };
147
+ data: {
148
+ readAt?: Date | null;
149
+ };
150
+ }): Promise<DocumentNotificationRow>;
151
+ updateMany(args: {
152
+ where: {
153
+ userId: string;
154
+ readAt?: null;
155
+ };
156
+ data: {
157
+ readAt: Date;
158
+ };
159
+ }): Promise<{
160
+ count: number;
161
+ }>;
162
+ count(args: {
163
+ where: {
164
+ userId: string;
165
+ readAt: null;
166
+ };
167
+ }): Promise<number>;
168
+ };
169
+ }
170
+ export declare function toNotificationDTO(row: DocumentNotificationRow): NotificationDTO;
171
+ export declare function isNotificationKind(value: unknown): value is NotificationKind;
172
+ /**
173
+ * Insert a new notification. The payload's `kind` field is the source
174
+ * of truth; `kind` on the DB row is derived from it so callers can't
175
+ * drift them apart.
176
+ */
177
+ export declare function createNotification(db: NotificationsDB, input: CreateNotificationInput): Promise<NotificationsResult<NotificationDTO>>;
178
+ /**
179
+ * List notifications for a user, newest first. By default returns only
180
+ * unread; pass `includeRead: true` for the full feed (the bell dropdown
181
+ * uses both depending on the tab the user is on).
182
+ */
183
+ export declare function listForUser(db: NotificationsDB, filter: ListNotificationsFilter): Promise<NotificationsResult<NotificationDTO[]>>;
184
+ /** Count unread notifications for the bell badge. */
185
+ export declare function unreadCount(db: NotificationsDB, userId: string): Promise<NotificationsResult<number>>;
186
+ /** Mark a single notification read (idempotent). */
187
+ export declare function markRead(db: NotificationsDB, id: string, actor: {
188
+ userId: string;
189
+ }): Promise<NotificationsResult<NotificationDTO>>;
190
+ /** Mark every unread notification for the user as read. */
191
+ export declare function markAllRead(db: NotificationsDB, userId: string): Promise<NotificationsResult<{
192
+ count: number;
193
+ }>>;
194
+ /**
195
+ * Cheap excerpt builder for notification payloads. Markdown is kept as-is;
196
+ * we just trim whitespace and clip to 120 chars so the bell can render
197
+ * a single-line preview.
198
+ */
199
+ export declare function excerptForNotification(body: string, max?: number): string;
200
+ /**
201
+ * Extract `@<userId>` mentions from a comment body. We intentionally
202
+ * match the *user id* form here (`@user_abc123`) — a slice-7 follow-up
203
+ * can wire up `@username` resolution against the User table.
204
+ *
205
+ * Returns a deduplicated array of mentioned user ids; the caller filters
206
+ * out the actor before issuing notifications.
207
+ */
208
+ export declare function extractMentionedUserIds(body: string): string[];
209
+ //# sourceMappingURL=notifications.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifications.d.ts","sourceRoot":"","sources":["../../src/realtime/notifications.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Per-user notifications driven by collaboration events.
3
+ *
4
+ * Owns the pure notification logic: kind discriminator, payload validation,
5
+ * permission gates, and a storage-agnostic CRUD surface. The HTTP layer
6
+ * (in `api/handlers.ts`) is a thin wrapper that handles auth + JSON
7
+ * envelope; the comments hook layer (slice 6, `notification-triggers.ts`)
8
+ * calls these helpers when a comment lifecycle event fires.
9
+ *
10
+ * Storage shape — see `packages/cms-core/prisma/cms-schema.prisma`, model
11
+ * `DocumentNotification`. We require only a minimal Prisma surface so the
12
+ * helpers can be tested against an in-memory fake.
13
+ *
14
+ * ## Discriminated payloads
15
+ *
16
+ * The `payload` Json column varies by `kind`. The TypeScript types here
17
+ * are the source of truth for each variant; the service helpers narrow
18
+ * by `kind` so callers can't accidentally insert a malformed payload.
19
+ *
20
+ * ## Mentions
21
+ *
22
+ * Slice 6 ships a deliberately simple mention parser: `@<userId>` tokens
23
+ * in a comment body trigger a `comment_mention` notification. A richer
24
+ * `@username` parser that resolves against the user table is a Phase 4
25
+ * follow-up — the storage shape already supports it.
26
+ */
27
+ /** Hard cap on returned notifications per `listForUser` call. */
28
+ export const MAX_NOTIFICATION_LIST_PAGE_SIZE = 200;
29
+ /** Default page size when the caller does not request one. */
30
+ export const DEFAULT_NOTIFICATION_LIST_PAGE_SIZE = 50;
31
+ export function toNotificationDTO(row) {
32
+ // The payload comes back as `unknown` from Prisma's Json column; we
33
+ // re-tag it with the `kind` discriminator so the DTO is strictly typed
34
+ // on the way out. Storage-level validation in `createNotification`
35
+ // guarantees the payload matches the kind.
36
+ const payload = {
37
+ kind: row.kind,
38
+ ...(row.payload ?? {}),
39
+ };
40
+ return {
41
+ id: row.id,
42
+ userId: row.userId,
43
+ kind: row.kind,
44
+ documentId: row.documentId,
45
+ commentId: row.commentId,
46
+ payload,
47
+ readAt: row.readAt ? row.readAt.toISOString() : null,
48
+ createdAt: row.createdAt.toISOString(),
49
+ };
50
+ }
51
+ const KIND_GUARDS = {
52
+ comment_reply: true,
53
+ comment_resolved: true,
54
+ comment_mention: true,
55
+ };
56
+ export function isNotificationKind(value) {
57
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(KIND_GUARDS, value);
58
+ }
59
+ function validatePayload(payload) {
60
+ if (!payload || typeof payload !== 'object') {
61
+ return { code: 'validation', message: 'payload must be an object' };
62
+ }
63
+ if (!isNotificationKind(payload.kind)) {
64
+ return { code: 'validation', message: `unknown kind "${String(payload.kind)}"` };
65
+ }
66
+ if (payload.kind === 'comment_reply' || payload.kind === 'comment_mention') {
67
+ if (typeof payload.excerpt !== 'string') {
68
+ return { code: 'validation', message: 'excerpt is required' };
69
+ }
70
+ }
71
+ if (payload.kind === 'comment_resolved') {
72
+ if (typeof payload.rootExcerpt !== 'string') {
73
+ return { code: 'validation', message: 'rootExcerpt is required' };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * Insert a new notification. The payload's `kind` field is the source
80
+ * of truth; `kind` on the DB row is derived from it so callers can't
81
+ * drift them apart.
82
+ */
83
+ export async function createNotification(db, input) {
84
+ if (!input.userId || typeof input.userId !== 'string') {
85
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
86
+ }
87
+ const validationError = validatePayload(input.payload);
88
+ if (validationError)
89
+ return { ok: false, error: validationError };
90
+ // Strip the kind tag from the stored JSON — it's already in the
91
+ // dedicated column.
92
+ const { kind, ...rest } = input.payload;
93
+ const row = await db.documentNotification.create({
94
+ data: {
95
+ userId: input.userId,
96
+ kind,
97
+ documentId: input.documentId ?? null,
98
+ commentId: input.commentId ?? null,
99
+ payload: rest,
100
+ readAt: null,
101
+ },
102
+ });
103
+ return { ok: true, value: toNotificationDTO(row) };
104
+ }
105
+ /** Resolve a notification by id, only if it belongs to `userId`. */
106
+ async function loadOwned(db, id, userId) {
107
+ const row = await db.documentNotification.findUnique({ where: { id } });
108
+ if (!row)
109
+ return { ok: false, error: { code: 'not_found', message: 'Notification not found' } };
110
+ if (row.userId !== userId) {
111
+ return { ok: false, error: { code: 'forbidden', message: 'Not your notification' } };
112
+ }
113
+ return { ok: true, value: row };
114
+ }
115
+ /**
116
+ * List notifications for a user, newest first. By default returns only
117
+ * unread; pass `includeRead: true` for the full feed (the bell dropdown
118
+ * uses both depending on the tab the user is on).
119
+ */
120
+ export async function listForUser(db, filter) {
121
+ if (!filter.userId) {
122
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
123
+ }
124
+ const take = clampPageSize(filter.pageSize);
125
+ const where = { userId: filter.userId };
126
+ if (!filter.includeRead)
127
+ where.readAt = null;
128
+ const rows = await db.documentNotification.findMany({
129
+ where,
130
+ orderBy: { createdAt: 'desc' },
131
+ take,
132
+ });
133
+ return { ok: true, value: rows.map(toNotificationDTO) };
134
+ }
135
+ /** Count unread notifications for the bell badge. */
136
+ export async function unreadCount(db, userId) {
137
+ if (!userId) {
138
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
139
+ }
140
+ const count = await db.documentNotification.count({
141
+ where: { userId, readAt: null },
142
+ });
143
+ return { ok: true, value: count };
144
+ }
145
+ /** Mark a single notification read (idempotent). */
146
+ export async function markRead(db, id, actor) {
147
+ if (!id) {
148
+ return { ok: false, error: { code: 'validation', message: 'id is required' } };
149
+ }
150
+ const loaded = await loadOwned(db, id, actor.userId);
151
+ if (!loaded.ok)
152
+ return loaded;
153
+ if (loaded.value.readAt) {
154
+ return { ok: true, value: toNotificationDTO(loaded.value) };
155
+ }
156
+ const updated = await db.documentNotification.update({
157
+ where: { id },
158
+ data: { readAt: new Date() },
159
+ });
160
+ return { ok: true, value: toNotificationDTO(updated) };
161
+ }
162
+ /** Mark every unread notification for the user as read. */
163
+ export async function markAllRead(db, userId) {
164
+ if (!userId) {
165
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
166
+ }
167
+ const out = await db.documentNotification.updateMany({
168
+ where: { userId, readAt: null },
169
+ data: { readAt: new Date() },
170
+ });
171
+ return { ok: true, value: { count: out.count } };
172
+ }
173
+ function clampPageSize(requested) {
174
+ if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) {
175
+ return DEFAULT_NOTIFICATION_LIST_PAGE_SIZE;
176
+ }
177
+ return Math.min(Math.max(1, Math.floor(requested)), MAX_NOTIFICATION_LIST_PAGE_SIZE);
178
+ }
179
+ /**
180
+ * Cheap excerpt builder for notification payloads. Markdown is kept as-is;
181
+ * we just trim whitespace and clip to 120 chars so the bell can render
182
+ * a single-line preview.
183
+ */
184
+ export function excerptForNotification(body, max = 120) {
185
+ const cleaned = body.replace(/\s+/g, ' ').trim();
186
+ if (cleaned.length <= max)
187
+ return cleaned;
188
+ return `${cleaned.slice(0, max - 1)}…`;
189
+ }
190
+ /**
191
+ * Extract `@<userId>` mentions from a comment body. We intentionally
192
+ * match the *user id* form here (`@user_abc123`) — a slice-7 follow-up
193
+ * can wire up `@username` resolution against the User table.
194
+ *
195
+ * Returns a deduplicated array of mentioned user ids; the caller filters
196
+ * out the actor before issuing notifications.
197
+ */
198
+ export function extractMentionedUserIds(body) {
199
+ if (typeof body !== 'string' || body.length === 0)
200
+ return [];
201
+ const seen = new Set();
202
+ const re = /@([a-zA-Z0-9_-]{3,64})\b/g;
203
+ let match;
204
+ while ((match = re.exec(body)) !== null) {
205
+ seen.add(match[1]);
206
+ }
207
+ return Array.from(seen);
208
+ }
209
+ //# sourceMappingURL=notifications.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifications.js","sourceRoot":"","sources":["../../src/realtime/notifications.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Prisma-backed `DocumentPersistence` for `@actuate-media/realtime`.
3
+ *
4
+ * Reads and writes the `DocumentCRDT` table (see
5
+ * `prisma/cms-schema.prisma`). The adapter is intentionally narrow — it only
6
+ * implements the two methods declared by the realtime package, so it can be
7
+ * swapped for an S3 / Redis / Postgres-LO adapter without touching the
8
+ * gateway code.
9
+ *
10
+ * The `save` path also keeps the `stateVector` column in sync so future
11
+ * versions of the gateway can answer `SyncStep1` without ever booting a
12
+ * `Y.Doc` (a future optimisation for hot-path late joins).
13
+ */
14
+ import type { DocumentPersistence } from '@actuate-media/realtime';
15
+ /** Minimal subset of the Prisma client the persistence adapter needs. */
16
+ export interface PrismaDocumentCRDTClient {
17
+ documentCRDT: {
18
+ findUnique(args: {
19
+ where: {
20
+ documentId: string;
21
+ };
22
+ select?: {
23
+ state?: boolean;
24
+ stateVector?: boolean;
25
+ version?: boolean;
26
+ };
27
+ }): Promise<{
28
+ state: Uint8Array;
29
+ stateVector?: Uint8Array;
30
+ version?: number;
31
+ } | null>;
32
+ upsert(args: {
33
+ where: {
34
+ documentId: string;
35
+ };
36
+ create: {
37
+ documentId: string;
38
+ state: Uint8Array;
39
+ stateVector: Uint8Array;
40
+ version: number;
41
+ lastUpdatedById?: string | null;
42
+ };
43
+ update: {
44
+ state: Uint8Array;
45
+ stateVector: Uint8Array;
46
+ version: {
47
+ increment: number;
48
+ };
49
+ lastUpdatedById?: string | null;
50
+ };
51
+ }): Promise<{
52
+ documentId: string;
53
+ version: number;
54
+ }>;
55
+ };
56
+ }
57
+ export interface PrismaDocumentPersistenceOptions {
58
+ /**
59
+ * Optional default editor id used for `lastUpdatedById` when the gateway
60
+ * does not supply one. In production the gateway derives this from the
61
+ * authenticated session per save.
62
+ */
63
+ lastUpdatedById?: string | null;
64
+ }
65
+ /**
66
+ * Create a `DocumentPersistence` backed by a `DocumentCRDT` Prisma table.
67
+ *
68
+ * The returned object satisfies the realtime package's contract so it can
69
+ * be passed straight to `bindPersistence(room, persistence)`.
70
+ */
71
+ export declare function createPrismaDocumentPersistence(db: PrismaDocumentCRDTClient, options?: PrismaDocumentPersistenceOptions): DocumentPersistence;
72
+ //# sourceMappingURL=persistence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/realtime/persistence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;AAGlE,yEAAyE;AACzE,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE;QACZ,UAAU,CAAC,IAAI,EAAE;YACf,KAAK,EAAE;gBAAE,UAAU,EAAE,MAAM,CAAA;aAAE,CAAA;YAC7B,MAAM,CAAC,EAAE;gBAAE,KAAK,CAAC,EAAE,OAAO,CAAC;gBAAC,WAAW,CAAC,EAAE,OAAO,CAAC;gBAAC,OAAO,CAAC,EAAE,OAAO,CAAA;aAAE,CAAA;SACvE,GAAG,OAAO,CAAC;YAAE,KAAK,EAAE,UAAU,CAAC;YAAC,WAAW,CAAC,EAAE,UAAU,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAC,CAAA;QACrF,MAAM,CAAC,IAAI,EAAE;YACX,KAAK,EAAE;gBAAE,UAAU,EAAE,MAAM,CAAA;aAAE,CAAA;YAC7B,MAAM,EAAE;gBACN,UAAU,EAAE,MAAM,CAAA;gBAClB,KAAK,EAAE,UAAU,CAAA;gBACjB,WAAW,EAAE,UAAU,CAAA;gBACvB,OAAO,EAAE,MAAM,CAAA;gBACf,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;aAChC,CAAA;YACD,MAAM,EAAE;gBACN,KAAK,EAAE,UAAU,CAAA;gBACjB,WAAW,EAAE,UAAU,CAAA;gBACvB,OAAO,EAAE;oBAAE,SAAS,EAAE,MAAM,CAAA;iBAAE,CAAA;gBAC9B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;aAChC,CAAA;SACF,GAAG,OAAO,CAAC;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KACrD,CAAA;CACF;AAED,MAAM,WAAW,gCAAgC;IAC/C;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAChC;AAED;;;;;GAKG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,wBAAwB,EAC5B,OAAO,GAAE,gCAAqC,GAC7C,mBAAmB,CAwCrB"}
@@ -0,0 +1,53 @@
1
+ import * as Y from 'yjs';
2
+ /**
3
+ * Create a `DocumentPersistence` backed by a `DocumentCRDT` Prisma table.
4
+ *
5
+ * The returned object satisfies the realtime package's contract so it can
6
+ * be passed straight to `bindPersistence(room, persistence)`.
7
+ */
8
+ export function createPrismaDocumentPersistence(db, options = {}) {
9
+ return {
10
+ async load(documentId) {
11
+ const row = await db.documentCRDT.findUnique({
12
+ where: { documentId },
13
+ select: { state: true },
14
+ });
15
+ if (!row)
16
+ return null;
17
+ // Defensive copy: callers may mutate the buffer once we return it
18
+ // (Prisma typically gives back a fresh Uint8Array, but we don't want
19
+ // to rely on internal guarantees here).
20
+ return copyBytes(row.state);
21
+ },
22
+ async save(documentId, state) {
23
+ // Compute the new state vector from the encoded state. Cheap — Yjs
24
+ // decodes the update structure but skips construction of clients.
25
+ const doc = new Y.Doc();
26
+ Y.applyUpdate(doc, state);
27
+ const stateVector = Y.encodeStateVector(doc);
28
+ doc.destroy();
29
+ await db.documentCRDT.upsert({
30
+ where: { documentId },
31
+ create: {
32
+ documentId,
33
+ state: copyBytes(state),
34
+ stateVector,
35
+ version: 1,
36
+ lastUpdatedById: options.lastUpdatedById ?? null,
37
+ },
38
+ update: {
39
+ state: copyBytes(state),
40
+ stateVector,
41
+ version: { increment: 1 },
42
+ lastUpdatedById: options.lastUpdatedById ?? null,
43
+ },
44
+ });
45
+ },
46
+ };
47
+ }
48
+ function copyBytes(src) {
49
+ const dst = new Uint8Array(src.length);
50
+ dst.set(src);
51
+ return dst;
52
+ }
53
+ //# sourceMappingURL=persistence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.js","sourceRoot":"","sources":["../../src/realtime/persistence.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAqCxB;;;;;GAKG;AACH,MAAM,UAAU,+BAA+B,CAC7C,EAA4B,EAC5B,UAA4C,EAAE;IAE9C,OAAO;QACL,KAAK,CAAC,IAAI,CAAC,UAAU;YACnB,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,YAAY,CAAC,UAAU,CAAC;gBAC3C,KAAK,EAAE,EAAE,UAAU,EAAE;gBACrB,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;aACxB,CAAC,CAAA;YACF,IAAI,CAAC,GAAG;gBAAE,OAAO,IAAI,CAAA;YACrB,kEAAkE;YAClE,qEAAqE;YACrE,wCAAwC;YACxC,OAAO,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QAC7B,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK;YAC1B,mEAAmE;YACnE,kEAAkE;YAClE,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,GAAG,EAAE,CAAA;YACvB,CAAC,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACzB,MAAM,WAAW,GAAG,CAAC,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAA;YAC5C,GAAG,CAAC,OAAO,EAAE,CAAA;YAEb,MAAM,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC;gBAC3B,KAAK,EAAE,EAAE,UAAU,EAAE;gBACrB,MAAM,EAAE;oBACN,UAAU;oBACV,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC;oBACvB,WAAW;oBACX,OAAO,EAAE,CAAC;oBACV,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,IAAI;iBACjD;gBACD,MAAM,EAAE;oBACN,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC;oBACvB,WAAW;oBACX,OAAO,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE;oBACzB,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,IAAI;iBACjD;aACF,CAAC,CAAA;QACJ,CAAC;KACF,CAAA;AACH,CAAC;AAED,SAAS,SAAS,CAAC,GAAe;IAChC,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IACtC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACZ,OAAO,GAAG,CAAA;AACZ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-core",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -59,6 +59,11 @@
59
59
  "import": "./dist/page-builder/index.js",
60
60
  "default": "./dist/page-builder/index.js"
61
61
  },
62
+ "./realtime": {
63
+ "types": "./dist/realtime/index.d.ts",
64
+ "import": "./dist/realtime/index.js",
65
+ "default": "./dist/realtime/index.js"
66
+ },
62
67
  "./generated/client": {
63
68
  "types": "./generated/client.d.ts",
64
69
  "import": "./generated/client.js",
@@ -74,18 +79,22 @@
74
79
  "graphql-yoga": "^5.19.0",
75
80
  "jose": "^6.0.0",
76
81
  "pg": "^8.20.0",
77
- "sanitize-html": "^2.17.3",
82
+ "sanitize-html": "^2.17.4",
78
83
  "sharp": "^0.34.5",
79
- "zod": "^3.23.0"
84
+ "yjs": "^13.6.0",
85
+ "zod": "^3.23.0",
86
+ "@actuate-media/realtime": "0.1.0"
80
87
  },
81
88
  "devDependencies": {
82
89
  "@types/sanitize-html": "^2.16.1",
83
90
  "@types/sharp": "^0.32.0",
91
+ "@types/ws": "^8.5.10",
84
92
  "dotenv": "^17.3.1",
85
93
  "prisma": "^7.8.0",
86
94
  "tsx": "^4.0.0",
87
95
  "typescript": "^5.7.0",
88
- "vitest": "^3.0.0"
96
+ "vitest": "^3.0.0",
97
+ "ws": "^8.18.0"
89
98
  },
90
99
  "files": [
91
100
  "dist",