@goscribe/server 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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/mcq-test.cjs +36 -0
  5. package/package.json +9 -2
  6. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  7. package/prisma/schema.prisma +471 -324
  8. package/src/context.ts +4 -1
  9. package/src/lib/activity_human_description.test.ts +28 -0
  10. package/src/lib/activity_human_description.ts +239 -0
  11. package/src/lib/activity_log_service.test.ts +37 -0
  12. package/src/lib/activity_log_service.ts +353 -0
  13. package/src/lib/ai-session.ts +79 -51
  14. package/src/lib/email.ts +213 -29
  15. package/src/lib/env.ts +23 -6
  16. package/src/lib/inference.ts +2 -2
  17. package/src/lib/notification-service.test.ts +106 -0
  18. package/src/lib/notification-service.ts +677 -0
  19. package/src/lib/prisma.ts +6 -1
  20. package/src/lib/pusher.ts +86 -2
  21. package/src/lib/stripe.ts +39 -0
  22. package/src/lib/subscription_service.ts +722 -0
  23. package/src/lib/usage_service.ts +74 -0
  24. package/src/lib/worksheet-generation.test.ts +31 -0
  25. package/src/lib/worksheet-generation.ts +139 -0
  26. package/src/routers/_app.ts +9 -0
  27. package/src/routers/admin.ts +710 -0
  28. package/src/routers/annotations.ts +41 -0
  29. package/src/routers/auth.ts +338 -28
  30. package/src/routers/copilot.ts +719 -0
  31. package/src/routers/flashcards.ts +201 -68
  32. package/src/routers/members.ts +280 -80
  33. package/src/routers/notifications.ts +142 -0
  34. package/src/routers/payment.ts +448 -0
  35. package/src/routers/podcast.ts +112 -83
  36. package/src/routers/studyguide.ts +12 -0
  37. package/src/routers/worksheets.ts +289 -66
  38. package/src/routers/workspace.ts +329 -122
  39. package/src/scripts/purge-deleted-users.ts +167 -0
  40. package/src/server.ts +137 -11
  41. package/src/services/flashcard-progress.service.ts +49 -37
  42. package/src/trpc.ts +184 -5
  43. package/test-generate.js +30 -0
  44. package/test-ratio.cjs +9 -0
  45. package/zod-test.cjs +22 -0
  46. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  47. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  48. package/prisma/seed.mjs +0 -135
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { TRPCError } from '@trpc/server';
3
3
  import { router, authedProcedure } from '../trpc.js';
4
+ import { notifyStudyGuideCommentAdded } from '../lib/notification-service.js';
4
5
 
5
6
  export const annotations = router({
6
7
  // List all highlights (with nested comments) for an artifact version
@@ -122,6 +123,46 @@ export const annotations = router({
122
123
  },
123
124
  });
124
125
 
126
+ const after = await ctx.db.studyGuideHighlight.findUnique({
127
+ where: { id: input.highlightId },
128
+ include: {
129
+ comments: { select: { userId: true } },
130
+ artifactVersion: {
131
+ include: {
132
+ artifact: {
133
+ select: { id: true, title: true, workspaceId: true, type: true },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ });
139
+
140
+ if (after?.artifactVersion?.artifact) {
141
+ const art = after.artifactVersion.artifact;
142
+ const recipientIds = new Set<string>();
143
+ if (after.userId !== ctx.session.user.id) {
144
+ recipientIds.add(after.userId);
145
+ }
146
+ for (const c of after.comments) {
147
+ if (c.userId !== ctx.session.user.id) {
148
+ recipientIds.add(c.userId);
149
+ }
150
+ }
151
+ const authorName =
152
+ comment.user.name?.trim() || 'Someone';
153
+ await notifyStudyGuideCommentAdded(ctx.db, {
154
+ authorUserId: ctx.session.user.id,
155
+ authorName,
156
+ content: input.content,
157
+ highlightId: input.highlightId,
158
+ commentId: comment.id,
159
+ workspaceId: art.workspaceId,
160
+ artifactId: art.id,
161
+ artifactTitle: art.title,
162
+ recipientUserIds: [...recipientIds],
163
+ }).catch(() => {});
164
+ }
165
+
125
166
  return comment;
126
167
  }),
127
168
 
@@ -1,19 +1,47 @@
1
1
  import { z } from 'zod';
2
2
  import { router, publicProcedure, authedProcedure } from '../trpc.js';
3
+ import PusherService from '../lib/pusher.js';
4
+ import { logger } from '../lib/logger.js';
3
5
  import bcrypt from 'bcryptjs';
4
6
  import { serialize } from 'cookie';
5
7
  import crypto from 'node:crypto';
6
8
  import { TRPCError } from '@trpc/server';
7
9
  import { supabaseClient } from '../lib/storage.js';
8
- import { sendVerificationEmail } from '../lib/email.js';
10
+ import {
11
+ sendVerificationEmail,
12
+ sendAccountDeletionScheduledEmail,
13
+ sendAccountRestoredEmail,
14
+ sendPasswordResetEmail,
15
+ } from '../lib/email.js';
16
+ import { createStripeCustomer } from '../lib/stripe.js';
17
+ import {
18
+ notifyAdminsAccountDeletionScheduled,
19
+ notifyAdminsOnSignup,
20
+ } from '../lib/notification-service.js';
9
21
 
10
22
  // Helper to create custom auth token
23
+ const passwordFieldSchema = z
24
+ .string()
25
+ .min(8, 'Password must be at least 8 characters')
26
+ .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
27
+ .regex(/[0-9]/, 'Password must contain at least one number')
28
+ .regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character');
29
+
30
+ function hashPasswordResetToken(rawToken: string): string {
31
+ return crypto.createHash('sha256').update(rawToken, 'utf8').digest('hex');
32
+ }
33
+
34
+ /** Use until `npx prisma generate` runs after adding PasswordResetToken to the schema. */
35
+ function passwordResetDb(ctx: { db: any }) {
36
+ return ctx.db.passwordResetToken;
37
+ }
38
+
11
39
  function createCustomAuthToken(userId: string): string {
12
40
  const secret = process.env.AUTH_SECRET;
13
41
  if (!secret) {
14
42
  throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: "AUTH_SECRET is not set" });
15
43
  }
16
-
44
+
17
45
  const base64UserId = Buffer.from(userId, 'utf8').toString('base64url');
18
46
  const hmac = crypto.createHmac('sha256', secret);
19
47
  hmac.update(base64UserId);
@@ -26,7 +54,7 @@ export const auth = router({
26
54
  .input(z.object({
27
55
  name: z.string().min(1),
28
56
  }))
29
- .mutation(async ({ctx, input}) => {
57
+ .mutation(async ({ ctx, input }) => {
30
58
  const { name } = input;
31
59
 
32
60
  await ctx.db.user.update({
@@ -43,19 +71,66 @@ export const auth = router({
43
71
  message: 'Profile updated successfully',
44
72
  };
45
73
  }),
46
- uploadProfilePicture: publicProcedure
47
- .mutation(async ({ctx, input}) => {
48
- const objectKey = `profile_picture_${ctx.session.user.id}`;
74
+ changePassword: authedProcedure
75
+ .input(z.object({
76
+ currentPassword: z.string().min(1, "Current password is required"),
77
+ newPassword: passwordFieldSchema,
78
+ }))
79
+ .mutation(async ({ ctx, input }) => {
80
+ const user = await ctx.db.user.findUnique({
81
+ where: { id: ctx.session.user.id },
82
+ select: { id: true, passwordHash: true },
83
+ });
84
+
85
+ if (!user) {
86
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
87
+ }
88
+
89
+ if (!user.passwordHash) {
90
+ throw new TRPCError({
91
+ code: 'BAD_REQUEST',
92
+ message: 'Password change is unavailable for this account.',
93
+ });
94
+ }
95
+
96
+ const validCurrentPassword = await bcrypt.compare(input.currentPassword, user.passwordHash);
97
+ if (!validCurrentPassword) {
98
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Current password is incorrect' });
99
+ }
100
+
101
+ const isSamePassword = await bcrypt.compare(input.newPassword, user.passwordHash);
102
+ if (isSamePassword) {
103
+ throw new TRPCError({
104
+ code: 'BAD_REQUEST',
105
+ message: 'New password must be different from current password',
106
+ });
107
+ }
108
+
109
+ const newHash = await bcrypt.hash(input.newPassword, 10);
110
+ await ctx.db.user.update({
111
+ where: { id: user.id },
112
+ data: { passwordHash: newHash },
113
+ });
114
+
115
+ return { success: true, message: 'Password changed successfully' };
116
+ }),
117
+ uploadProfilePicture: authedProcedure
118
+ .mutation(async ({ ctx }) => {
119
+ const userId = ctx.session.user.id;
120
+ logger.info(`Generating upload URL for user ${userId}`, 'AUTH');
121
+ const objectKey = `profile_picture_${userId}`;
49
122
  const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
50
123
  .from('media')
51
- .createSignedUploadUrl(objectKey, { upsert: true }); // 5 minutes
124
+ .createSignedUploadUrl(objectKey, { upsert: true });
125
+
52
126
  if (signedUrlError) {
127
+ logger.error(`Failed to generate upload URL: ${signedUrlError.message}`, 'AUTH');
53
128
  throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to generate upload URL: ${signedUrlError.message}` });
54
129
  }
55
130
 
56
- await ctx.db.fileAsset.create({
131
+ const fileAsset = await ctx.db.fileAsset.create({
57
132
  data: {
58
- userId: ctx.session.user.id,
133
+ userId: userId,
59
134
  name: 'Profile Picture',
60
135
  mimeType: 'image/jpeg',
61
136
  size: 0,
@@ -64,17 +139,31 @@ export const auth = router({
64
139
  },
65
140
  });
66
141
 
142
+ await ctx.db.user.update({
143
+ where: { id: userId },
144
+ data: {
145
+ fileAssetId: fileAsset.id,
146
+ },
147
+ });
148
+
149
+ logger.info(`Profile picture asset created and linked for user ${userId}`, 'AUTH');
67
150
  return {
68
151
  success: true,
69
152
  message: 'Profile picture uploaded successfully',
70
153
  signedUrl: signedUrlData.signedUrl,
71
154
  };
72
155
  }),
156
+ confirmProfileUpdate: authedProcedure
157
+ .mutation(async ({ ctx }) => {
158
+ logger.info(`Confirming profile update for user ${ctx.session.user.id}`, 'AUTH');
159
+ await PusherService.emitProfileUpdate(ctx.session.user.id);
160
+ return { success: true };
161
+ }),
73
162
  signup: publicProcedure
74
163
  .input(z.object({
75
164
  name: z.string().min(1),
76
165
  email: z.string().email(),
77
- password: z.string().min(6),
166
+ password: passwordFieldSchema,
78
167
  }))
79
168
  .mutation(async ({ ctx, input }) => {
80
169
  const existing = await ctx.db.user.findUnique({
@@ -86,15 +175,27 @@ export const auth = router({
86
175
 
87
176
  const hash = await bcrypt.hash(input.password, 10);
88
177
 
178
+ // Get default "User" role
179
+ const userRole = await ctx.db.role.findUnique({
180
+ where: { name: 'User' },
181
+ });
182
+
89
183
  const user = await ctx.db.user.create({
90
184
  data: {
91
185
  name: input.name,
92
186
  email: input.email,
93
187
  passwordHash: hash,
188
+ roleId: userRole?.id,
94
189
  // emailVerified is null -- user must verify
95
190
  },
96
191
  });
97
192
 
193
+ await notifyAdminsOnSignup(ctx.db, {
194
+ id: user.id,
195
+ name: user.name,
196
+ email: user.email,
197
+ });
198
+
98
199
  // Create verification token (24h expiry)
99
200
  const token = crypto.randomUUID();
100
201
  await ctx.db.verificationToken.create({
@@ -106,7 +207,17 @@ export const auth = router({
106
207
  });
107
208
 
108
209
  // Send verification email (non-blocking)
109
- sendVerificationEmail(input.email, token, input.name).catch(() => {});
210
+ sendVerificationEmail(input.email, token, input.name).catch(() => { });
211
+
212
+ // Create Stripe Customer (non-blocking for registration, but we want it)
213
+ createStripeCustomer(input.email, input.name).then(async (stripeCustomerId) => {
214
+ if (stripeCustomerId) {
215
+ await ctx.db.user.update({
216
+ where: { id: user.id },
217
+ data: { stripe_customer_id: stripeCustomerId }
218
+ }).catch((err: any) => logger.error(`Failed to update user with stripe_customer_id: ${err.message}`, 'AUTH'));
219
+ }
220
+ });
110
221
 
111
222
  return { id: user.id, email: user.email, name: user.name };
112
223
  }),
@@ -127,7 +238,7 @@ export const auth = router({
127
238
 
128
239
  if (record.expires < new Date()) {
129
240
  // Clean up expired token
130
- await ctx.db.verificationToken.delete({ where: { token: input.token } });
241
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
131
242
  throw new TRPCError({ code: 'BAD_REQUEST', message: 'Verification link has expired. Please request a new one.' });
132
243
  }
133
244
 
@@ -138,7 +249,7 @@ export const auth = router({
138
249
  });
139
250
 
140
251
  // Delete used token
141
- await ctx.db.verificationToken.delete({ where: { token: input.token } });
252
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
142
253
 
143
254
  return { success: true, message: 'Email verified successfully' };
144
255
  }),
@@ -197,6 +308,10 @@ export const auth = router({
197
308
  throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
198
309
  }
199
310
 
311
+ if (user.deletedAt) {
312
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "Account scheduled for deletion. Please check your email for a restore link." });
313
+ }
314
+
200
315
  const valid = await bcrypt.compare(input.password, user.passwordHash!);
201
316
  if (!valid) {
202
317
  throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
@@ -215,40 +330,235 @@ export const auth = router({
215
330
  domain: isProduction ? "server-w8mz.onrender.com" : undefined,
216
331
  maxAge: 60 * 60 * 24 * 30, // 30 days
217
332
  });
218
-
333
+
219
334
  ctx.res.setHeader("Set-Cookie", cookieValue);
220
335
 
221
336
 
222
- return {
223
- id: user.id,
224
- email: user.email,
225
- name: user.name,
337
+ return {
338
+ id: user.id,
339
+ email: user.email,
340
+ name: user.name,
226
341
  token: authToken
227
342
  };
228
343
  }),
344
+
345
+ /**
346
+ * Request a password reset email. Always returns the same message (no email enumeration).
347
+ */
348
+ requestPasswordReset: publicProcedure
349
+ .input(z.object({ email: z.string().email() }))
350
+ .mutation(async ({ ctx, input }) => {
351
+ const email = input.email.trim().toLowerCase();
352
+ const generic = {
353
+ success: true as const,
354
+ message:
355
+ 'If an account exists for this email, we sent password reset instructions.',
356
+ };
357
+
358
+ const user = await ctx.db.user.findUnique({
359
+ where: { email },
360
+ select: {
361
+ id: true,
362
+ email: true,
363
+ name: true,
364
+ passwordHash: true,
365
+ deletedAt: true,
366
+ },
367
+ });
368
+
369
+ if (!user?.passwordHash || user.deletedAt || !user.email) {
370
+ return generic;
371
+ }
372
+
373
+ await passwordResetDb(ctx).deleteMany({
374
+ where: { userId: user.id, usedAt: null },
375
+ });
376
+
377
+ const rawToken = crypto.randomBytes(32).toString('hex');
378
+ const tokenHash = hashPasswordResetToken(rawToken);
379
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
380
+
381
+ await passwordResetDb(ctx).create({
382
+ data: {
383
+ userId: user.id,
384
+ tokenHash,
385
+ expiresAt,
386
+ },
387
+ });
388
+
389
+ sendPasswordResetEmail(user.email, rawToken, user.name).catch(() => {});
390
+
391
+ return generic;
392
+ }),
393
+
394
+ /**
395
+ * Complete password reset using the token from the email link.
396
+ */
397
+ resetPassword: publicProcedure
398
+ .input(
399
+ z.object({
400
+ token: z.string().min(1),
401
+ newPassword: passwordFieldSchema,
402
+ })
403
+ )
404
+ .mutation(async ({ ctx, input }) => {
405
+ const tokenHash = hashPasswordResetToken(input.token);
406
+
407
+ const record = await passwordResetDb(ctx).findUnique({
408
+ where: { tokenHash },
409
+ });
410
+
411
+ if (!record || record.usedAt || record.expiresAt < new Date()) {
412
+ throw new TRPCError({
413
+ code: 'BAD_REQUEST',
414
+ message: 'Invalid or expired reset link. Please request a new one.',
415
+ });
416
+ }
417
+
418
+ const newHash = await bcrypt.hash(input.newPassword, 10);
419
+
420
+ await ctx.db.user.update({
421
+ where: { id: record.userId },
422
+ data: { passwordHash: newHash },
423
+ });
424
+
425
+ await passwordResetDb(ctx).update({
426
+ where: { id: record.id },
427
+ data: { usedAt: new Date() },
428
+ });
429
+
430
+ await passwordResetDb(ctx).deleteMany({
431
+ where: { userId: record.userId, id: { not: record.id } },
432
+ });
433
+
434
+ return { success: true, message: 'Password updated. You can sign in now.' };
435
+ }),
436
+
229
437
  getSession: publicProcedure.query(async ({ ctx }) => {
230
438
  // Just return the current session from context
231
439
  if (!ctx.session) {
232
440
  throw new TRPCError({ code: 'UNAUTHORIZED', message: "No session found" });
233
441
  }
234
442
 
443
+ const userId = (ctx.session as any).user.id;
235
444
  const user = await ctx.db.user.findUnique({
236
- where: { id: (ctx.session as any).user.id },
445
+ where: { id: userId },
446
+ include: {
447
+ profilePicture: true,
448
+ role: true,
449
+ }
237
450
  });
238
451
 
239
452
  if (!user) {
240
453
  throw new TRPCError({ code: 'NOT_FOUND', message: "User not found" });
241
454
  }
242
455
 
243
- return {
244
- user: {
245
- id: user.id,
246
- email: user.email,
247
- name: user.name,
456
+ const profilePictureUrl = user.profilePicture?.objectKey
457
+ ? `/profile-picture/${user.profilePicture.objectKey}?t=${new Date(user.updatedAt).getTime()}`
458
+ : null;
459
+
460
+ logger.info(`Session fetched for user ${userId}, profilePicture: ${profilePictureUrl}`, 'AUTH');
461
+
462
+ return {
463
+ user: {
464
+ id: user.id,
465
+ email: user.email,
466
+ name: user.name,
248
467
  emailVerified: !!user.emailVerified,
249
- }
468
+ profilePicture: profilePictureUrl,
469
+ role: user.role,
470
+ }
250
471
  };
251
472
  }),
473
+ requestAccountDeletion: authedProcedure
474
+ .mutation(async ({ ctx }) => {
475
+ const user = await ctx.db.user.findUnique({
476
+ where: { id: ctx.session.user.id },
477
+ });
478
+
479
+ if (!user) {
480
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
481
+ }
482
+
483
+ // Mark user as deleted
484
+ await ctx.db.user.update({
485
+ where: { id: user.id },
486
+ data: { deletedAt: new Date() },
487
+ });
488
+
489
+ await notifyAdminsAccountDeletionScheduled(ctx.db, {
490
+ id: user.id,
491
+ name: user.name,
492
+ email: user.email,
493
+ }).catch(() => {});
494
+
495
+ // Clear existing restore tokens
496
+ await ctx.db.verificationToken.deleteMany({
497
+ where: { identifier: `restore-${user.email}` },
498
+ });
499
+
500
+ // Create restore token (30 days expiry)
501
+ const token = crypto.randomUUID();
502
+ await ctx.db.verificationToken.create({
503
+ data: {
504
+ identifier: `restore-${user.email}`,
505
+ token,
506
+ expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
507
+ },
508
+ });
509
+
510
+ // Send email
511
+ if (user.email) {
512
+ sendAccountDeletionScheduledEmail(user.email, token).catch(() => { });
513
+ }
514
+
515
+ // Log out user by clearing cookie
516
+ ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
517
+ httpOnly: true,
518
+ secure: process.env.NODE_ENV === "production",
519
+ sameSite: "lax",
520
+ path: "/",
521
+ maxAge: 0,
522
+ }));
523
+
524
+ return { success: true, message: 'Account scheduled for deletion' };
525
+ }),
526
+
527
+ restoreAccount: publicProcedure
528
+ .input(z.object({
529
+ token: z.string(),
530
+ }))
531
+ .mutation(async ({ ctx, input }) => {
532
+ const record = await ctx.db.verificationToken.findUnique({
533
+ where: { token: input.token },
534
+ });
535
+
536
+ if (!record || !record.identifier.startsWith('restore-')) {
537
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired restore link' });
538
+ }
539
+
540
+ if (record.expires < new Date()) {
541
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
542
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Restore link has expired.' });
543
+ }
544
+
545
+ const email = record.identifier.replace('restore-', '');
546
+
547
+ // Mark user as restored
548
+ await ctx.db.user.update({
549
+ where: { email },
550
+ data: { deletedAt: null },
551
+ });
552
+
553
+ // Delete used token
554
+ await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
555
+
556
+ // Send confirmation email
557
+ sendAccountRestoredEmail(email).catch(() => { });
558
+
559
+ return { success: true, message: 'Account restored successfully' };
560
+ }),
561
+
252
562
  logout: publicProcedure.mutation(async ({ ctx }) => {
253
563
  const token = ctx.cookies["auth_token"];
254
564
 
@@ -256,9 +566,9 @@ export const auth = router({
256
566
  throw new TRPCError({ code: 'UNAUTHORIZED', message: "No token found" });
257
567
  }
258
568
 
259
- await ctx.db.session.delete({
260
- where: { id: token },
261
- });
569
+ // We don't need to delete from db.session because we use a stateless
570
+ // custom HMAC auth system (auth_token cookie).
571
+
262
572
 
263
573
  ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
264
574
  httpOnly: true,