@goscribe/server 1.2.0 → 1.3.1

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 (126) hide show
  1. package/check-difficulty.cjs +14 -0
  2. package/check-questions.cjs +14 -0
  3. package/db-summary.cjs +22 -0
  4. package/dist/context.d.ts +5 -1
  5. package/dist/lib/activity_human_description.d.ts +13 -0
  6. package/dist/lib/activity_human_description.js +221 -0
  7. package/dist/lib/activity_human_description.test.d.ts +1 -0
  8. package/dist/lib/activity_human_description.test.js +16 -0
  9. package/dist/lib/activity_log_service.d.ts +87 -0
  10. package/dist/lib/activity_log_service.js +276 -0
  11. package/dist/lib/activity_log_service.test.d.ts +1 -0
  12. package/dist/lib/activity_log_service.test.js +27 -0
  13. package/dist/lib/ai-session.d.ts +15 -2
  14. package/dist/lib/ai-session.js +147 -85
  15. package/dist/lib/constants.d.ts +13 -0
  16. package/dist/lib/constants.js +12 -0
  17. package/dist/lib/email.d.ts +11 -0
  18. package/dist/lib/email.js +193 -0
  19. package/dist/lib/env.d.ts +13 -0
  20. package/dist/lib/env.js +16 -0
  21. package/dist/lib/inference.d.ts +4 -1
  22. package/dist/lib/inference.js +3 -3
  23. package/dist/lib/logger.d.ts +4 -4
  24. package/dist/lib/logger.js +30 -8
  25. package/dist/lib/notification-service.d.ts +152 -0
  26. package/dist/lib/notification-service.js +473 -0
  27. package/dist/lib/notification-service.test.d.ts +1 -0
  28. package/dist/lib/notification-service.test.js +87 -0
  29. package/dist/lib/prisma.d.ts +2 -1
  30. package/dist/lib/prisma.js +5 -1
  31. package/dist/lib/pusher.d.ts +23 -0
  32. package/dist/lib/pusher.js +69 -5
  33. package/dist/lib/retry.d.ts +15 -0
  34. package/dist/lib/retry.js +37 -0
  35. package/dist/lib/storage.js +2 -2
  36. package/dist/lib/stripe.d.ts +9 -0
  37. package/dist/lib/stripe.js +36 -0
  38. package/dist/lib/subscription_service.d.ts +37 -0
  39. package/dist/lib/subscription_service.js +654 -0
  40. package/dist/lib/usage_service.d.ts +26 -0
  41. package/dist/lib/usage_service.js +59 -0
  42. package/dist/lib/worksheet-generation.d.ts +91 -0
  43. package/dist/lib/worksheet-generation.js +95 -0
  44. package/dist/lib/worksheet-generation.test.d.ts +1 -0
  45. package/dist/lib/worksheet-generation.test.js +20 -0
  46. package/dist/lib/workspace-access.d.ts +18 -0
  47. package/dist/lib/workspace-access.js +13 -0
  48. package/dist/routers/_app.d.ts +1349 -253
  49. package/dist/routers/_app.js +10 -0
  50. package/dist/routers/admin.d.ts +361 -0
  51. package/dist/routers/admin.js +633 -0
  52. package/dist/routers/annotations.d.ts +219 -0
  53. package/dist/routers/annotations.js +187 -0
  54. package/dist/routers/auth.d.ts +88 -7
  55. package/dist/routers/auth.js +339 -19
  56. package/dist/routers/chat.d.ts +6 -12
  57. package/dist/routers/copilot.d.ts +199 -0
  58. package/dist/routers/copilot.js +571 -0
  59. package/dist/routers/flashcards.d.ts +47 -81
  60. package/dist/routers/flashcards.js +143 -27
  61. package/dist/routers/members.d.ts +36 -7
  62. package/dist/routers/members.js +200 -19
  63. package/dist/routers/notifications.d.ts +99 -0
  64. package/dist/routers/notifications.js +127 -0
  65. package/dist/routers/payment.d.ts +89 -0
  66. package/dist/routers/payment.js +403 -0
  67. package/dist/routers/podcast.d.ts +8 -13
  68. package/dist/routers/podcast.js +54 -31
  69. package/dist/routers/studyguide.d.ts +1 -29
  70. package/dist/routers/studyguide.js +80 -71
  71. package/dist/routers/worksheets.d.ts +105 -38
  72. package/dist/routers/worksheets.js +258 -68
  73. package/dist/routers/workspace.d.ts +139 -60
  74. package/dist/routers/workspace.js +455 -315
  75. package/dist/scripts/purge-deleted-users.d.ts +1 -0
  76. package/dist/scripts/purge-deleted-users.js +149 -0
  77. package/dist/server.js +130 -10
  78. package/dist/services/flashcard-progress.service.d.ts +18 -66
  79. package/dist/services/flashcard-progress.service.js +51 -42
  80. package/dist/trpc.d.ts +20 -21
  81. package/dist/trpc.js +150 -1
  82. package/mcq-test.cjs +36 -0
  83. package/package.json +9 -2
  84. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  85. package/prisma/schema.prisma +471 -324
  86. package/src/context.ts +4 -1
  87. package/src/lib/activity_human_description.test.ts +28 -0
  88. package/src/lib/activity_human_description.ts +239 -0
  89. package/src/lib/activity_log_service.test.ts +37 -0
  90. package/src/lib/activity_log_service.ts +353 -0
  91. package/src/lib/ai-session.ts +79 -51
  92. package/src/lib/email.ts +213 -29
  93. package/src/lib/env.ts +23 -6
  94. package/src/lib/inference.ts +2 -2
  95. package/src/lib/notification-service.test.ts +106 -0
  96. package/src/lib/notification-service.ts +677 -0
  97. package/src/lib/prisma.ts +6 -1
  98. package/src/lib/pusher.ts +86 -2
  99. package/src/lib/stripe.ts +39 -0
  100. package/src/lib/subscription_service.ts +722 -0
  101. package/src/lib/usage_service.ts +74 -0
  102. package/src/lib/worksheet-generation.test.ts +31 -0
  103. package/src/lib/worksheet-generation.ts +139 -0
  104. package/src/routers/_app.ts +9 -0
  105. package/src/routers/admin.ts +710 -0
  106. package/src/routers/annotations.ts +41 -0
  107. package/src/routers/auth.ts +338 -28
  108. package/src/routers/copilot.ts +719 -0
  109. package/src/routers/flashcards.ts +201 -68
  110. package/src/routers/members.ts +280 -80
  111. package/src/routers/notifications.ts +142 -0
  112. package/src/routers/payment.ts +448 -0
  113. package/src/routers/podcast.ts +112 -83
  114. package/src/routers/studyguide.ts +12 -0
  115. package/src/routers/worksheets.ts +289 -66
  116. package/src/routers/workspace.ts +329 -122
  117. package/src/scripts/purge-deleted-users.ts +167 -0
  118. package/src/server.ts +137 -11
  119. package/src/services/flashcard-progress.service.ts +49 -37
  120. package/src/trpc.ts +184 -5
  121. package/test-generate.js +30 -0
  122. package/test-ratio.cjs +9 -0
  123. package/zod-test.cjs +22 -0
  124. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  125. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  126. package/prisma/seed.mjs +0 -135
@@ -0,0 +1,473 @@
1
+ import PusherService from './pusher.js';
2
+ export const NotificationType = {
3
+ GENERAL: 'GENERAL',
4
+ USER_SIGNED_UP: 'USER_SIGNED_UP',
5
+ ACCOUNT_DELETION_SCHEDULED: 'ACCOUNT_DELETION_SCHEDULED',
6
+ ACCOUNT_PERMANENTLY_DELETED: 'ACCOUNT_PERMANENTLY_DELETED',
7
+ WORKSPACE_INVITE_RECEIVED: 'WORKSPACE_INVITE_RECEIVED',
8
+ WORKSPACE_INVITE_ACCEPTED: 'WORKSPACE_INVITE_ACCEPTED',
9
+ WORKSPACE_DELETED: 'WORKSPACE_DELETED',
10
+ PAYMENT_SUCCEEDED: 'PAYMENT_SUCCEEDED',
11
+ SUBSCRIPTION_ACTIVATED: 'SUBSCRIPTION_ACTIVATED',
12
+ SUBSCRIPTION_PAYMENT_SUCCEEDED: 'SUBSCRIPTION_PAYMENT_SUCCEEDED',
13
+ SUBSCRIPTION_CANCELED: 'SUBSCRIPTION_CANCELED',
14
+ PAYMENT_FAILED: 'PAYMENT_FAILED',
15
+ WORKSPACE_ROLE_CHANGED: 'WORKSPACE_ROLE_CHANGED',
16
+ WORKSPACE_MEMBERSHIP_REMOVED: 'WORKSPACE_MEMBERSHIP_REMOVED',
17
+ STUDY_GUIDE_COMMENT_ADDED: 'STUDY_GUIDE_COMMENT_ADDED',
18
+ ARTIFACT_READY: 'ARTIFACT_READY',
19
+ ARTIFACT_FAILED: 'ARTIFACT_FAILED',
20
+ };
21
+ export function artifactTypeLabel(artifactType) {
22
+ const map = {
23
+ STUDY_GUIDE: 'Study guide',
24
+ FLASHCARD_SET: 'Flashcards',
25
+ WORKSHEET: 'Worksheet',
26
+ PODCAST_EPISODE: 'Podcast episode',
27
+ MEETING_SUMMARY: 'Meeting summary',
28
+ };
29
+ return map[artifactType] ?? artifactType;
30
+ }
31
+ export function buildArtifactActionUrl(workspaceId, artifactId, artifactType) {
32
+ switch (artifactType) {
33
+ case 'STUDY_GUIDE':
34
+ return `/workspace/${workspaceId}/study-guide`;
35
+ case 'FLASHCARD_SET':
36
+ return `/workspace/${workspaceId}/flashcards`;
37
+ case 'WORKSHEET':
38
+ return artifactId
39
+ ? `/workspace/${workspaceId}/worksheet/${artifactId}`
40
+ : `/workspace/${workspaceId}/worksheet`;
41
+ case 'PODCAST_EPISODE':
42
+ return artifactId
43
+ ? `/workspace/${workspaceId}/podcasts/${artifactId}`
44
+ : `/workspace/${workspaceId}/podcasts`;
45
+ case 'MEETING_SUMMARY':
46
+ return `/workspace/${workspaceId}`;
47
+ default:
48
+ return `/workspace/${workspaceId}`;
49
+ }
50
+ }
51
+ function extractMentionEmails(text) {
52
+ const re = /@([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
53
+ const out = [];
54
+ let m;
55
+ while ((m = re.exec(text)) !== null) {
56
+ out.push(m[1].toLowerCase());
57
+ }
58
+ return [...new Set(out)];
59
+ }
60
+ async function resolveUserIdsByEmailsInWorkspace(db, workspaceId, emails) {
61
+ if (!emails.length)
62
+ return [];
63
+ const ids = new Set();
64
+ for (const raw of emails) {
65
+ const user = await db.user.findFirst({
66
+ where: { email: { equals: raw, mode: 'insensitive' }, deletedAt: null },
67
+ select: { id: true },
68
+ });
69
+ if (!user)
70
+ continue;
71
+ const member = await db.workspaceMember.findFirst({
72
+ where: { workspaceId, userId: user.id },
73
+ select: { id: true },
74
+ });
75
+ const owned = await db.workspace.findFirst({
76
+ where: { id: workspaceId, ownerId: user.id },
77
+ select: { id: true },
78
+ });
79
+ if (member || owned)
80
+ ids.add(user.id);
81
+ }
82
+ return [...ids];
83
+ }
84
+ async function getUnreadCount(db, userId) {
85
+ return db.notification.count({
86
+ where: { userId, read: false },
87
+ });
88
+ }
89
+ export async function createNotification(db, input) {
90
+ if (input.sourceId) {
91
+ const existing = await db.notification.findFirst({
92
+ where: {
93
+ userId: input.userId,
94
+ type: input.type,
95
+ sourceId: input.sourceId,
96
+ },
97
+ // Avoid selecting all columns (including drifted optional columns) for idempotency check.
98
+ select: { id: true },
99
+ });
100
+ if (existing) {
101
+ return existing;
102
+ }
103
+ }
104
+ const created = await db.notification.create({
105
+ data: {
106
+ userId: input.userId,
107
+ actorUserId: input.actorUserId,
108
+ workspaceId: input.workspaceId,
109
+ type: input.type,
110
+ title: input.title,
111
+ body: input.body,
112
+ content: input.body,
113
+ actionUrl: input.actionUrl,
114
+ metadata: input.metadata,
115
+ priority: input.priority ?? 'NORMAL',
116
+ sourceId: input.sourceId,
117
+ },
118
+ });
119
+ const unreadCount = await getUnreadCount(db, input.userId);
120
+ await PusherService.emitNotificationNew(input.userId, {
121
+ notificationId: created.id,
122
+ type: created.type,
123
+ title: created.title,
124
+ unreadCount,
125
+ createdAt: created.createdAt.toISOString(),
126
+ });
127
+ return created;
128
+ }
129
+ export async function createManyNotifications(db, inputs) {
130
+ if (!inputs.length)
131
+ return [];
132
+ const created = [];
133
+ for (const input of inputs) {
134
+ created.push(await createNotification(db, input));
135
+ }
136
+ return created;
137
+ }
138
+ export async function getSystemAdminIds(db) {
139
+ const admins = await db.user.findMany({
140
+ where: {
141
+ role: { name: 'System Admin' },
142
+ deletedAt: null,
143
+ },
144
+ select: { id: true },
145
+ });
146
+ return admins.map((admin) => admin.id);
147
+ }
148
+ export async function notifyAdminsOnSignup(db, user) {
149
+ const adminIds = await getSystemAdminIds(db);
150
+ if (!adminIds.length)
151
+ return;
152
+ const displayName = user.name || user.email || 'New user';
153
+ await createManyNotifications(db, adminIds.map((adminId) => ({
154
+ userId: adminId,
155
+ actorUserId: user.id,
156
+ type: NotificationType.USER_SIGNED_UP,
157
+ title: 'New user signed up',
158
+ body: `${displayName} just created an account.`,
159
+ actionUrl: '/admin/users',
160
+ metadata: { newUserId: user.id, email: user.email },
161
+ sourceId: `signup:${user.id}`,
162
+ priority: 'NORMAL',
163
+ })));
164
+ }
165
+ export async function notifyAdminsAccountDeletionScheduled(db, user) {
166
+ const adminIds = await getSystemAdminIds(db);
167
+ const targets = adminIds.filter((id) => id !== user.id);
168
+ if (!targets.length)
169
+ return;
170
+ const displayName = user.name || user.email || 'A user';
171
+ await createManyNotifications(db, targets.map((adminId) => ({
172
+ userId: adminId,
173
+ actorUserId: user.id,
174
+ type: NotificationType.ACCOUNT_DELETION_SCHEDULED,
175
+ title: `${displayName} scheduled account deletion`,
176
+ body: 'Their account will be permanently deleted after the grace period unless they restore it.',
177
+ actionUrl: '/admin/users',
178
+ metadata: { deletedUserId: user.id, email: user.email },
179
+ sourceId: `account-deletion-scheduled:${user.id}`,
180
+ priority: 'HIGH',
181
+ })));
182
+ }
183
+ export async function notifyAdminsAccountPermanentlyDeleted(db, user) {
184
+ const adminIds = await getSystemAdminIds(db);
185
+ const targets = adminIds.filter((id) => id !== user.id);
186
+ if (!targets.length)
187
+ return;
188
+ const displayName = user.name || user.email || 'A user';
189
+ await createManyNotifications(db, targets.map((adminId) => ({
190
+ userId: adminId,
191
+ type: NotificationType.ACCOUNT_PERMANENTLY_DELETED,
192
+ title: `${displayName} permanently deleted`,
193
+ body: 'Their account and data have been removed after the grace period.',
194
+ actionUrl: '/admin/users',
195
+ metadata: { deletedUserId: user.id, email: user.email },
196
+ sourceId: `account-purged:${user.id}`,
197
+ priority: 'HIGH',
198
+ })));
199
+ }
200
+ export async function notifyInviteRecipient(db, input) {
201
+ await createNotification(db, {
202
+ userId: input.invitedUserId,
203
+ actorUserId: input.inviterUserId,
204
+ workspaceId: input.workspaceId,
205
+ type: NotificationType.WORKSPACE_INVITE_RECEIVED,
206
+ title: 'Workspace invite received',
207
+ body: `${input.inviterName || 'A teammate'} invited you to join "${input.workspaceTitle}".`,
208
+ actionUrl: `/accept-invite?token=${encodeURIComponent(input.invitationToken)}`,
209
+ metadata: {
210
+ workspaceId: input.workspaceId,
211
+ workspaceName: input.workspaceTitle,
212
+ invitationId: input.invitationId,
213
+ inviterUserId: input.inviterUserId,
214
+ },
215
+ sourceId: `invite:${input.invitationId}`,
216
+ });
217
+ }
218
+ export async function notifyInviteAccepted(db, input) {
219
+ const uniqueRecipients = Array.from(new Set(input.recipientUserIds.filter(Boolean)));
220
+ await createManyNotifications(db, uniqueRecipients.map((userId) => ({
221
+ userId,
222
+ actorUserId: input.actorUserId,
223
+ workspaceId: input.workspaceId,
224
+ type: NotificationType.WORKSPACE_INVITE_ACCEPTED,
225
+ title: 'Workspace invite accepted',
226
+ body: `${input.memberName || 'A user'} joined "${input.workspaceTitle}".`,
227
+ actionUrl: `/workspace/${input.workspaceId}`,
228
+ metadata: {
229
+ workspaceId: input.workspaceId,
230
+ workspaceTitle: input.workspaceTitle,
231
+ invitationId: input.invitationId,
232
+ },
233
+ sourceId: `invite-accepted:${input.invitationId}:${userId}`,
234
+ })));
235
+ }
236
+ export async function notifyWorkspaceDeleted(db, input) {
237
+ const uniqueRecipients = Array.from(new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId)));
238
+ if (!uniqueRecipients.length)
239
+ return;
240
+ await createManyNotifications(db, uniqueRecipients.map((userId) => ({
241
+ userId,
242
+ actorUserId: input.actorUserId,
243
+ workspaceId: input.workspaceId,
244
+ type: NotificationType.WORKSPACE_DELETED,
245
+ title: `${input.actorName} deleted workspace`,
246
+ body: `"${input.workspaceTitle}" was deleted.`,
247
+ actionUrl: '/storage',
248
+ metadata: {
249
+ workspaceId: input.workspaceId,
250
+ workspaceTitle: input.workspaceTitle,
251
+ actorName: input.actorName,
252
+ },
253
+ sourceId: `workspace-deleted:${input.workspaceId}:${userId}`,
254
+ priority: 'HIGH',
255
+ })));
256
+ }
257
+ async function createBillingNotificationsForUserAndAdmins(db, input) {
258
+ await createNotification(db, input);
259
+ const adminIds = await getSystemAdminIds(db);
260
+ const adminTargets = adminIds.filter((id) => id !== input.userId);
261
+ if (!adminTargets.length)
262
+ return;
263
+ await createManyNotifications(db, adminTargets.map((adminId) => ({
264
+ userId: adminId,
265
+ actorUserId: input.userId,
266
+ workspaceId: input.workspaceId,
267
+ type: input.type,
268
+ title: input.adminTitle || input.title,
269
+ body: input.adminBody,
270
+ actionUrl: '/admin/users',
271
+ metadata: input.metadata,
272
+ priority: input.priority,
273
+ sourceId: input.sourceId ? `${input.sourceId}:admin:${adminId}` : undefined,
274
+ })));
275
+ }
276
+ export async function notifyPaymentSucceeded(db, input) {
277
+ const planLabel = input.planName || 'selected plan';
278
+ await createBillingNotificationsForUserAndAdmins(db, {
279
+ userId: input.userId,
280
+ type: NotificationType.PAYMENT_SUCCEEDED,
281
+ title: 'Payment successful',
282
+ adminTitle: 'User payment successful',
283
+ body: `Your payment succeeded for ${planLabel}.`,
284
+ adminBody: `A user completed a successful payment for ${planLabel}.`,
285
+ actionUrl: '/settings',
286
+ metadata: {
287
+ planId: input.planId,
288
+ planName: input.planName,
289
+ stripeSessionId: input.stripeSessionId,
290
+ amountPaid: input.amountPaid,
291
+ },
292
+ sourceId: input.stripeSessionId ? `checkout:${input.stripeSessionId}` : undefined,
293
+ });
294
+ }
295
+ export async function notifySubscriptionActivated(db, input) {
296
+ const planLabel = input.planName || 'your plan';
297
+ await createBillingNotificationsForUserAndAdmins(db, {
298
+ userId: input.userId,
299
+ type: NotificationType.SUBSCRIPTION_ACTIVATED,
300
+ title: `You subscribed to ${planLabel}`,
301
+ adminTitle: `User subscribed to ${planLabel}`,
302
+ body: `Your ${planLabel} subscription is active.`,
303
+ adminBody: `A user started a ${planLabel} subscription.`,
304
+ actionUrl: '/settings',
305
+ metadata: {
306
+ planId: input.planId,
307
+ planName: input.planName,
308
+ stripeSubscriptionId: input.stripeSubscriptionId,
309
+ },
310
+ sourceId: `subscription-created:${input.stripeSubscriptionId}`,
311
+ });
312
+ }
313
+ export async function notifySubscriptionPaymentSucceeded(db, input) {
314
+ const planLabel = input.planName || 'subscription';
315
+ await createBillingNotificationsForUserAndAdmins(db, {
316
+ userId: input.userId,
317
+ type: NotificationType.SUBSCRIPTION_PAYMENT_SUCCEEDED,
318
+ title: 'Subscription payment successful',
319
+ adminTitle: 'User subscription payment successful',
320
+ body: `Your ${planLabel} payment was successful.`,
321
+ adminBody: `A user successfully paid for ${planLabel}.`,
322
+ actionUrl: '/settings',
323
+ metadata: {
324
+ planId: input.planId,
325
+ planName: input.planName,
326
+ stripeInvoiceId: input.stripeInvoiceId,
327
+ amountPaid: input.amountPaid,
328
+ },
329
+ sourceId: `invoice-paid:${input.stripeInvoiceId}`,
330
+ });
331
+ }
332
+ export async function notifySubscriptionCanceled(db, input) {
333
+ const planLabel = input.planName || 'subscription';
334
+ await createBillingNotificationsForUserAndAdmins(db, {
335
+ userId: input.userId,
336
+ type: NotificationType.SUBSCRIPTION_CANCELED,
337
+ title: 'Subscription canceled',
338
+ adminTitle: 'User subscription canceled',
339
+ body: `Your ${planLabel} subscription was canceled.`,
340
+ adminBody: `A user canceled ${planLabel}.`,
341
+ actionUrl: '/settings',
342
+ metadata: {
343
+ planId: input.planId,
344
+ planName: input.planName,
345
+ stripeSubscriptionId: input.stripeSubscriptionId,
346
+ },
347
+ sourceId: `subscription-canceled:${input.stripeSubscriptionId}`,
348
+ priority: 'HIGH',
349
+ });
350
+ }
351
+ export async function notifyPaymentFailed(db, input) {
352
+ const planLabel = input.planName || 'payment';
353
+ const sourceId = input.stripeInvoiceId
354
+ ? `invoice-failed:${input.stripeInvoiceId}`
355
+ : input.stripePaymentIntentId
356
+ ? `pi-failed:${input.stripePaymentIntentId}`
357
+ : undefined;
358
+ await createBillingNotificationsForUserAndAdmins(db, {
359
+ userId: input.userId,
360
+ type: NotificationType.PAYMENT_FAILED,
361
+ title: 'Payment failed',
362
+ adminTitle: 'User payment failed',
363
+ body: `Your ${planLabel} payment failed. Please update billing and retry.`,
364
+ adminBody: `A user payment failed for ${planLabel}.`,
365
+ actionUrl: '/settings',
366
+ metadata: {
367
+ planId: input.planId,
368
+ planName: input.planName,
369
+ stripeInvoiceId: input.stripeInvoiceId,
370
+ stripePaymentIntentId: input.stripePaymentIntentId,
371
+ },
372
+ sourceId,
373
+ priority: 'HIGH',
374
+ });
375
+ }
376
+ export async function notifyWorkspaceRoleChanged(db, input) {
377
+ const roleLabel = input.newRole === 'admin' ? 'admin' : 'member';
378
+ const oldLabel = input.oldRole === 'admin' ? 'admin' : 'member';
379
+ await createNotification(db, {
380
+ userId: input.memberUserId,
381
+ actorUserId: input.actorUserId,
382
+ workspaceId: input.workspaceId,
383
+ type: NotificationType.WORKSPACE_ROLE_CHANGED,
384
+ title: `Your role in "${input.workspaceTitle}" changed`,
385
+ body: `${input.actorName} changed your role from ${oldLabel} to ${roleLabel}.`,
386
+ actionUrl: `/workspace/${input.workspaceId}/members`,
387
+ metadata: {
388
+ oldRole: input.oldRole,
389
+ newRole: input.newRole,
390
+ workspaceTitle: input.workspaceTitle,
391
+ },
392
+ priority: 'NORMAL',
393
+ });
394
+ }
395
+ export async function notifyWorkspaceMembershipRemoved(db, input) {
396
+ await createNotification(db, {
397
+ userId: input.memberUserId,
398
+ actorUserId: input.actorUserId,
399
+ workspaceId: input.workspaceId,
400
+ type: NotificationType.WORKSPACE_MEMBERSHIP_REMOVED,
401
+ title: `Removed from "${input.workspaceTitle}"`,
402
+ body: `${input.actorName} removed you from this workspace.`,
403
+ actionUrl: '/storage',
404
+ metadata: { workspaceTitle: input.workspaceTitle },
405
+ priority: 'HIGH',
406
+ });
407
+ }
408
+ export async function notifyStudyGuideCommentAdded(db, input) {
409
+ const mentionEmails = extractMentionEmails(input.content);
410
+ const mentionUserIds = await resolveUserIdsByEmailsInWorkspace(db, input.workspaceId, mentionEmails);
411
+ const combined = new Set([
412
+ ...input.recipientUserIds.filter(Boolean),
413
+ ...mentionUserIds,
414
+ ]);
415
+ combined.delete(input.authorUserId);
416
+ const targets = [...combined];
417
+ if (!targets.length)
418
+ return;
419
+ const preview = input.content.length > 140
420
+ ? `${input.content.slice(0, 137)}...`
421
+ : input.content;
422
+ await createManyNotifications(db, targets.map((userId) => ({
423
+ userId,
424
+ actorUserId: input.authorUserId,
425
+ workspaceId: input.workspaceId,
426
+ type: NotificationType.STUDY_GUIDE_COMMENT_ADDED,
427
+ title: `${input.authorName} commented on "${input.artifactTitle}"`,
428
+ body: preview,
429
+ actionUrl: `/workspace/${input.workspaceId}/study-guide`,
430
+ metadata: {
431
+ artifactId: input.artifactId,
432
+ highlightId: input.highlightId,
433
+ commentId: input.commentId,
434
+ },
435
+ sourceId: `comment:${input.commentId}:${userId}`,
436
+ })));
437
+ }
438
+ export async function notifyArtifactReady(db, input) {
439
+ const kind = artifactTypeLabel(input.artifactType);
440
+ await createNotification(db, {
441
+ userId: input.userId,
442
+ workspaceId: input.workspaceId,
443
+ type: NotificationType.ARTIFACT_READY,
444
+ title: `${kind} ready`,
445
+ body: `"${input.title}" is ready to open.`,
446
+ actionUrl: buildArtifactActionUrl(input.workspaceId, input.artifactId, input.artifactType),
447
+ metadata: {
448
+ artifactId: input.artifactId,
449
+ artifactType: input.artifactType,
450
+ },
451
+ sourceId: `artifact-ready:${input.artifactId}`,
452
+ priority: 'NORMAL',
453
+ });
454
+ }
455
+ export async function notifyArtifactFailed(db, input) {
456
+ const kind = artifactTypeLabel(input.artifactType);
457
+ await createNotification(db, {
458
+ userId: input.userId,
459
+ workspaceId: input.workspaceId,
460
+ type: NotificationType.ARTIFACT_FAILED,
461
+ title: `${kind} generation failed`,
462
+ body: input.message,
463
+ actionUrl: buildArtifactActionUrl(input.workspaceId, input.artifactId, input.artifactType),
464
+ metadata: {
465
+ artifactId: input.artifactId,
466
+ artifactType: input.artifactType,
467
+ },
468
+ sourceId: input.artifactId
469
+ ? `artifact-failed:${input.artifactId}`
470
+ : undefined,
471
+ priority: 'HIGH',
472
+ });
473
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import PusherService from './pusher.js';
4
+ import { NotificationType, createNotification } from './notification-service.js';
5
+ function createFakeDb() {
6
+ const store = [];
7
+ const db = {
8
+ notification: {
9
+ async findFirst(args) {
10
+ const where = args?.where ?? {};
11
+ return (store.find((item) => {
12
+ if (where.userId && item.userId !== where.userId)
13
+ return false;
14
+ if (where.type && item.type !== where.type)
15
+ return false;
16
+ if (where.sourceId && item.sourceId !== where.sourceId)
17
+ return false;
18
+ return true;
19
+ }) ?? null);
20
+ },
21
+ async create(args) {
22
+ const data = args.data;
23
+ const item = {
24
+ id: `n_${store.length + 1}`,
25
+ userId: data.userId,
26
+ type: data.type,
27
+ title: data.title,
28
+ body: data.body,
29
+ content: data.content,
30
+ sourceId: data.sourceId,
31
+ createdAt: new Date(),
32
+ read: false,
33
+ };
34
+ store.push(item);
35
+ return item;
36
+ },
37
+ async count(args) {
38
+ const where = args?.where ?? {};
39
+ return store.filter((item) => {
40
+ if (where.userId && item.userId !== where.userId)
41
+ return false;
42
+ if (where.read !== undefined && item.read !== where.read)
43
+ return false;
44
+ return true;
45
+ }).length;
46
+ },
47
+ },
48
+ };
49
+ return { db, store };
50
+ }
51
+ test('createNotification deduplicates by sourceId', async () => {
52
+ const { db, store } = createFakeDb();
53
+ const originalEmit = PusherService.emitNotificationNew;
54
+ PusherService.emitNotificationNew = async () => { };
55
+ await createNotification(db, {
56
+ userId: 'u1',
57
+ type: NotificationType.PAYMENT_SUCCEEDED,
58
+ title: 'Payment successful',
59
+ body: 'Paid',
60
+ sourceId: 'source-1',
61
+ });
62
+ await createNotification(db, {
63
+ userId: 'u1',
64
+ type: NotificationType.PAYMENT_SUCCEEDED,
65
+ title: 'Payment successful',
66
+ body: 'Paid',
67
+ sourceId: 'source-1',
68
+ });
69
+ assert.equal(store.length, 1);
70
+ PusherService.emitNotificationNew = originalEmit;
71
+ });
72
+ test('createNotification emits unread count payload', async () => {
73
+ const { db } = createFakeDb();
74
+ const originalEmit = PusherService.emitNotificationNew;
75
+ let capturedUnread = -1;
76
+ PusherService.emitNotificationNew = async (_userId, payload) => {
77
+ capturedUnread = payload.unreadCount;
78
+ };
79
+ await createNotification(db, {
80
+ userId: 'u1',
81
+ type: NotificationType.GENERAL,
82
+ title: 'Hello',
83
+ body: 'World',
84
+ });
85
+ assert.equal(capturedUnread, 1);
86
+ PusherService.emitNotificationNew = originalEmit;
87
+ });
@@ -1,2 +1,3 @@
1
- import { PrismaClient } from "@prisma/client";
1
+ import { PrismaClient } from "../../node_modules/.prisma/client/index.js";
2
+ export { ArtifactType } from "../../node_modules/.prisma/client/index.js";
2
3
  export declare const prisma: PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
@@ -1,4 +1,8 @@
1
- import { PrismaClient } from "@prisma/client";
1
+ // Resolve the client from this package's generated output so types always match
2
+ // `prisma/schema.prisma` (the `@prisma/client` entry can resolve to a different
3
+ // generated client in multi-folder workspaces and lose model delegates in the IDE).
4
+ import { PrismaClient } from "../../node_modules/.prisma/client/index.js";
5
+ export { ArtifactType } from "../../node_modules/.prisma/client/index.js";
2
6
  const globalForPrisma = globalThis;
3
7
  export const prisma = globalForPrisma.prisma ??
4
8
  new PrismaClient({
@@ -1,15 +1,38 @@
1
1
  import Pusher from 'pusher';
2
2
  export declare const pusher: Pusher;
3
3
  export declare class PusherService {
4
+ static emitUserEvent(userId: string, eventName: string, data: any): Promise<void>;
4
5
  static emitTaskComplete(workspaceId: string, event: string, data: any): Promise<void>;
5
6
  static emitAnalysisComplete(workspaceId: string, analysisType: string, result: any): Promise<void>;
6
7
  static emitStudyGuideComplete(workspaceId: string, artifact: any): Promise<void>;
7
8
  static emitFlashcardComplete(workspaceId: string, artifact: any): Promise<void>;
8
9
  static emitWorksheetComplete(workspaceId: string, artifact: any): Promise<void>;
10
+ static emitWorksheetGenerationStart(workspaceId: string): Promise<void>;
11
+ static emitWorksheetNew(workspaceId: string, worksheet: any): Promise<void>;
12
+ static emitWorksheetGenerationComplete(workspaceId: string, worksheet: any): Promise<void>;
9
13
  static emitPodcastComplete(workspaceId: string, artifact: any): Promise<void>;
10
14
  static emitOverallComplete(workspaceId: string, filename: string, artifacts: any): Promise<void>;
11
15
  static emitError(workspaceId: string, error: string, analysisType?: string): Promise<void>;
12
16
  static emitAnalysisProgress(workspaceId: string, progress: any): Promise<void>;
13
17
  static emitChannelEvent(channelId: string, event: string, data: any): Promise<void>;
18
+ static emitMemberJoined(workspaceId: string, member: any): Promise<void>;
19
+ static emitProfileUpdate(userId: string): Promise<void>;
20
+ static emitLibraryUpdate(userId: string): Promise<void>;
21
+ static emitNotificationNew(userId: string, data: {
22
+ notificationId: string;
23
+ type: string;
24
+ title: string;
25
+ unreadCount: number;
26
+ createdAt: string;
27
+ }): Promise<void>;
28
+ static emitNotificationReadState(userId: string, data: {
29
+ unreadCount: number;
30
+ timestamp?: string;
31
+ }): Promise<void>;
32
+ static emitPaymentSuccess(userId: string, data: {
33
+ type: 'topup' | 'subscription';
34
+ resourceType?: string;
35
+ quantity?: string;
36
+ }): Promise<void>;
14
37
  }
15
38
  export default PusherService;