@goscribe/server 1.0.10 → 1.1.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 (83) hide show
  1. package/ANALYSIS_PROGRESS_SPEC.md +463 -0
  2. package/PROGRESS_QUICK_REFERENCE.md +239 -0
  3. package/dist/lib/ai-session.d.ts +20 -9
  4. package/dist/lib/ai-session.js +316 -80
  5. package/dist/lib/auth.d.ts +35 -2
  6. package/dist/lib/auth.js +88 -15
  7. package/dist/lib/env.d.ts +32 -0
  8. package/dist/lib/env.js +46 -0
  9. package/dist/lib/errors.d.ts +33 -0
  10. package/dist/lib/errors.js +78 -0
  11. package/dist/lib/inference.d.ts +4 -1
  12. package/dist/lib/inference.js +9 -11
  13. package/dist/lib/logger.d.ts +62 -0
  14. package/dist/lib/logger.js +342 -0
  15. package/dist/lib/podcast-prompts.d.ts +43 -0
  16. package/dist/lib/podcast-prompts.js +135 -0
  17. package/dist/lib/pusher.d.ts +1 -0
  18. package/dist/lib/pusher.js +14 -2
  19. package/dist/lib/storage.d.ts +3 -3
  20. package/dist/lib/storage.js +51 -47
  21. package/dist/lib/validation.d.ts +51 -0
  22. package/dist/lib/validation.js +64 -0
  23. package/dist/routers/_app.d.ts +697 -111
  24. package/dist/routers/_app.js +5 -0
  25. package/dist/routers/auth.d.ts +11 -1
  26. package/dist/routers/chat.d.ts +11 -1
  27. package/dist/routers/flashcards.d.ts +205 -6
  28. package/dist/routers/flashcards.js +144 -66
  29. package/dist/routers/members.d.ts +165 -0
  30. package/dist/routers/members.js +531 -0
  31. package/dist/routers/podcast.d.ts +78 -63
  32. package/dist/routers/podcast.js +330 -393
  33. package/dist/routers/studyguide.d.ts +11 -1
  34. package/dist/routers/worksheets.d.ts +124 -13
  35. package/dist/routers/worksheets.js +123 -50
  36. package/dist/routers/workspace.d.ts +213 -26
  37. package/dist/routers/workspace.js +303 -181
  38. package/dist/server.js +12 -4
  39. package/dist/services/flashcard-progress.service.d.ts +183 -0
  40. package/dist/services/flashcard-progress.service.js +383 -0
  41. package/dist/services/flashcard.service.d.ts +183 -0
  42. package/dist/services/flashcard.service.js +224 -0
  43. package/dist/services/podcast-segment-reorder.d.ts +0 -0
  44. package/dist/services/podcast-segment-reorder.js +107 -0
  45. package/dist/services/podcast.service.d.ts +0 -0
  46. package/dist/services/podcast.service.js +326 -0
  47. package/dist/services/worksheet.service.d.ts +0 -0
  48. package/dist/services/worksheet.service.js +295 -0
  49. package/dist/trpc.d.ts +13 -2
  50. package/dist/trpc.js +55 -6
  51. package/dist/types/index.d.ts +126 -0
  52. package/dist/types/index.js +1 -0
  53. package/package.json +3 -2
  54. package/prisma/schema.prisma +142 -4
  55. package/src/lib/ai-session.ts +356 -85
  56. package/src/lib/auth.ts +113 -19
  57. package/src/lib/env.ts +59 -0
  58. package/src/lib/errors.ts +92 -0
  59. package/src/lib/inference.ts +11 -11
  60. package/src/lib/logger.ts +405 -0
  61. package/src/lib/pusher.ts +15 -3
  62. package/src/lib/storage.ts +56 -51
  63. package/src/lib/validation.ts +75 -0
  64. package/src/routers/_app.ts +5 -0
  65. package/src/routers/chat.ts +2 -23
  66. package/src/routers/flashcards.ts +108 -24
  67. package/src/routers/members.ts +586 -0
  68. package/src/routers/podcast.ts +385 -420
  69. package/src/routers/worksheets.ts +117 -35
  70. package/src/routers/workspace.ts +328 -195
  71. package/src/server.ts +13 -4
  72. package/src/services/flashcard-progress.service.ts +541 -0
  73. package/src/trpc.ts +59 -6
  74. package/src/types/index.ts +165 -0
  75. package/AUTH_FRONTEND_SPEC.md +0 -21
  76. package/CHAT_FRONTEND_SPEC.md +0 -474
  77. package/DATABASE_SETUP.md +0 -165
  78. package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
  79. package/PODCAST_FRONTEND_SPEC.md +0 -595
  80. package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
  81. package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
  82. package/WORKSPACE_FRONTEND_SPEC.md +0 -47
  83. package/test-ai-integration.js +0 -134
@@ -0,0 +1,586 @@
1
+ import { z } from 'zod';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { router, publicProcedure, authedProcedure } from '../trpc.js';
4
+ import { logger } from '../lib/logger.js';
5
+
6
+ /**
7
+ * Members router for workspace member management
8
+ *
9
+ * Features:
10
+ * - Get workspace members
11
+ * - Invite new members via email
12
+ * - Accept invitations via UUID
13
+ * - Change member roles
14
+ * - Remove members
15
+ * - Get current user's role
16
+ */
17
+ export const members = router({
18
+ /**
19
+ * Get all members of a workspace
20
+ */
21
+ getMembers: authedProcedure
22
+ .input(z.object({
23
+ workspaceId: z.string(),
24
+ }))
25
+ .query(async ({ ctx, input }) => {
26
+ // Check if user has access to this workspace
27
+ const workspace = await ctx.db.workspace.findFirst({
28
+ where: {
29
+ id: input.workspaceId,
30
+ OR: [
31
+ { ownerId: ctx.session.user.id },
32
+ { members: { some: { userId: ctx.session.user.id } } }
33
+ ]
34
+ },
35
+ include: {
36
+ owner: {
37
+ select: {
38
+ id: true,
39
+ name: true,
40
+ email: true,
41
+ image: true,
42
+ }
43
+ },
44
+ members: {
45
+ include: {
46
+ user: {
47
+ select: {
48
+ id: true,
49
+ name: true,
50
+ email: true,
51
+ image: true,
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ });
58
+
59
+ if (!workspace) {
60
+ throw new TRPCError({
61
+ code: 'NOT_FOUND',
62
+ message: 'Workspace not found or access denied'
63
+ });
64
+ }
65
+
66
+ // Format members with roles
67
+ const members = [
68
+ {
69
+ id: workspace.owner.id,
70
+ name: workspace.owner.name || 'Unknown',
71
+ email: workspace.owner.email || '',
72
+ image: workspace.owner.image,
73
+ role: 'owner' as const,
74
+ joinedAt: workspace.createdAt,
75
+ },
76
+ ...workspace.members.map(membership => ({
77
+ id: membership.user.id,
78
+ name: membership.user.name || 'Unknown',
79
+ email: membership.user.email || '',
80
+ image: membership.user.image,
81
+ role: membership.role as 'admin' | 'member',
82
+ joinedAt: membership.joinedAt,
83
+ }))
84
+ ];
85
+
86
+ return members;
87
+ }),
88
+
89
+ /**
90
+ * Get current user's role in a workspace
91
+ */
92
+ getCurrentUserRole: authedProcedure
93
+ .input(z.object({
94
+ workspaceId: z.string(),
95
+ }))
96
+ .query(async ({ ctx, input }) => {
97
+ const workspace = await ctx.db.workspace.findFirst({
98
+ where: { id: input.workspaceId },
99
+ select: {
100
+ ownerId: true,
101
+ members: {
102
+ where: { userId: ctx.session.user.id },
103
+ select: { role: true }
104
+ }
105
+ }
106
+ });
107
+
108
+ if (!workspace) {
109
+ throw new TRPCError({
110
+ code: 'NOT_FOUND',
111
+ message: 'Workspace not found'
112
+ });
113
+ }
114
+
115
+ if (workspace.ownerId === ctx.session.user.id) {
116
+ return 'owner';
117
+ }
118
+
119
+ if (workspace.members.length > 0) {
120
+ return workspace.members[0].role as 'admin' | 'member';
121
+ }
122
+
123
+ throw new TRPCError({
124
+ code: 'FORBIDDEN',
125
+ message: 'Access denied to this workspace'
126
+ });
127
+ }),
128
+
129
+ /**
130
+ * Invite a new member to the workspace
131
+ */
132
+ inviteMember: authedProcedure
133
+ .input(z.object({
134
+ workspaceId: z.string(),
135
+ email: z.string().email(),
136
+ role: z.enum(['admin', 'member']).default('member'),
137
+ }))
138
+ .mutation(async ({ ctx, input }) => {
139
+ // Check if user is owner or admin of the workspace
140
+ const workspace = await ctx.db.workspace.findFirst({
141
+ where: {
142
+ id: input.workspaceId,
143
+ ownerId: ctx.session.user.id // Only owners can invite for now
144
+ }
145
+ });
146
+
147
+ if (!workspace) {
148
+ throw new TRPCError({
149
+ code: 'NOT_FOUND',
150
+ message: 'Workspace not found or insufficient permissions'
151
+ });
152
+ }
153
+
154
+ // Check if user is already a member
155
+ const existingMember = await ctx.db.user.findFirst({
156
+ where: {
157
+ email: input.email,
158
+ OR: [
159
+ { id: workspace.ownerId },
160
+ { workspaceMemberships: { some: { workspaceId: input.workspaceId } } }
161
+ ]
162
+ }
163
+ });
164
+
165
+ if (existingMember) {
166
+ throw new TRPCError({
167
+ code: 'BAD_REQUEST',
168
+ message: 'User is already a member of this workspace'
169
+ });
170
+ }
171
+
172
+ // Check if there's already a pending invitation
173
+ const existingInvitation = await ctx.db.workspaceInvitation.findFirst({
174
+ where: {
175
+ workspaceId: input.workspaceId,
176
+ email: input.email,
177
+ acceptedAt: null,
178
+ expiresAt: { gt: new Date() }
179
+ }
180
+ });
181
+
182
+ if (existingInvitation) {
183
+ throw new TRPCError({
184
+ code: 'BAD_REQUEST',
185
+ message: 'Invitation already sent to this email'
186
+ });
187
+ }
188
+
189
+ // Create invitation
190
+ const invitation = await ctx.db.workspaceInvitation.create({
191
+ data: {
192
+ workspaceId: input.workspaceId,
193
+ email: input.email,
194
+ role: input.role,
195
+ invitedById: ctx.session.user.id,
196
+ },
197
+ include: {
198
+ workspace: {
199
+ select: {
200
+ title: true,
201
+ owner: {
202
+ select: {
203
+ name: true,
204
+ email: true,
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ });
211
+
212
+ logger.info(`🎫 Invitation created for ${input.email} to workspace ${input.workspaceId} with role ${input.role}`, 'WORKSPACE', {
213
+ invitationId: invitation.id,
214
+ workspaceId: input.workspaceId,
215
+ email: input.email,
216
+ role: input.role,
217
+ invitedBy: ctx.session.user.id
218
+ });
219
+
220
+ // TODO: Send email notification here
221
+ // await sendInvitationEmail(invitation);
222
+
223
+ return {
224
+ invitationId: invitation.id,
225
+ token: invitation.token,
226
+ email: invitation.email,
227
+ role: invitation.role,
228
+ expiresAt: invitation.expiresAt,
229
+ workspaceTitle: invitation.workspace.title,
230
+ invitedByName: invitation.workspace.owner.name || invitation.workspace.owner.email,
231
+ };
232
+ }),
233
+
234
+ /**
235
+ * Accept an invitation (public endpoint)
236
+ */
237
+ acceptInvite: publicProcedure
238
+ .input(z.object({
239
+ token: z.string(),
240
+ }))
241
+ .mutation(async ({ ctx, input }) => {
242
+ // Find the invitation
243
+ const invitation = await ctx.db.workspaceInvitation.findFirst({
244
+ where: {
245
+ token: input.token,
246
+ acceptedAt: null,
247
+ expiresAt: { gt: new Date() }
248
+ },
249
+ include: {
250
+ workspace: {
251
+ select: {
252
+ id: true,
253
+ title: true,
254
+ owner: {
255
+ select: {
256
+ name: true,
257
+ email: true,
258
+ }
259
+ }
260
+ }
261
+ }
262
+ }
263
+ });
264
+
265
+ if (!invitation) {
266
+ throw new TRPCError({
267
+ code: 'NOT_FOUND',
268
+ message: 'Invalid or expired invitation'
269
+ });
270
+ }
271
+
272
+ // Check if user is authenticated
273
+ if (!ctx.session?.user) {
274
+ throw new TRPCError({
275
+ code: 'UNAUTHORIZED',
276
+ message: 'Please log in to accept this invitation'
277
+ });
278
+ }
279
+
280
+ // Check if the email matches the user's email
281
+ if (ctx.session.user.email !== invitation.email) {
282
+ throw new TRPCError({
283
+ code: 'BAD_REQUEST',
284
+ message: 'This invitation was sent to a different email address'
285
+ });
286
+ }
287
+
288
+ // Check if user is already a member
289
+ const isAlreadyMember = await ctx.db.workspace.findFirst({
290
+ where: {
291
+ id: invitation.workspaceId,
292
+ OR: [
293
+ { ownerId: ctx.session.user.id },
294
+ { members: { some: { userId: ctx.session.user.id } } }
295
+ ]
296
+ }
297
+ });
298
+
299
+ if (isAlreadyMember) {
300
+ // Mark invitation as accepted even if already a member
301
+ await ctx.db.workspaceInvitation.update({
302
+ where: { id: invitation.id },
303
+ data: { acceptedAt: new Date() }
304
+ });
305
+
306
+ throw new TRPCError({
307
+ code: 'BAD_REQUEST',
308
+ message: 'You are already a member of this workspace'
309
+ });
310
+ }
311
+
312
+ // Add user to workspace with proper role
313
+ await ctx.db.workspaceMember.create({
314
+ data: {
315
+ workspaceId: invitation.workspaceId,
316
+ userId: ctx.session.user.id,
317
+ role: invitation.role,
318
+ }
319
+ });
320
+
321
+ // Mark invitation as accepted
322
+ await ctx.db.workspaceInvitation.update({
323
+ where: { id: invitation.id },
324
+ data: { acceptedAt: new Date() }
325
+ });
326
+
327
+ logger.info(`✅ Invitation accepted by ${ctx.session.user.id} for workspace ${invitation.workspaceId}`, 'WORKSPACE', {
328
+ invitationId: invitation.id,
329
+ workspaceId: invitation.workspaceId,
330
+ userId: ctx.session.user.id,
331
+ email: invitation.email
332
+ });
333
+
334
+ return {
335
+ workspaceId: invitation.workspaceId,
336
+ workspaceTitle: invitation.workspace.title,
337
+ role: invitation.role,
338
+ ownerName: invitation.workspace.owner.name || invitation.workspace.owner.email,
339
+ };
340
+ }),
341
+
342
+ /**
343
+ * Change a member's role (owner only)
344
+ */
345
+ changeMemberRole: authedProcedure
346
+ .input(z.object({
347
+ workspaceId: z.string(),
348
+ memberId: z.string(),
349
+ role: z.enum(['admin', 'member']),
350
+ }))
351
+ .mutation(async ({ ctx, input }) => {
352
+ // Check if user is owner of the workspace
353
+ const workspace = await ctx.db.workspace.findFirst({
354
+ where: {
355
+ id: input.workspaceId,
356
+ ownerId: ctx.session.user.id
357
+ }
358
+ });
359
+
360
+ if (!workspace) {
361
+ throw new TRPCError({
362
+ code: 'NOT_FOUND',
363
+ message: 'Workspace not found or insufficient permissions'
364
+ });
365
+ }
366
+
367
+ // Check if member exists and is not the owner
368
+ if (input.memberId === workspace.ownerId) {
369
+ throw new TRPCError({
370
+ code: 'BAD_REQUEST',
371
+ message: 'Cannot change owner role'
372
+ });
373
+ }
374
+
375
+ const member = await ctx.db.workspaceMember.findFirst({
376
+ where: {
377
+ workspaceId: input.workspaceId,
378
+ userId: input.memberId
379
+ }
380
+ });
381
+
382
+ if (!member) {
383
+ throw new TRPCError({
384
+ code: 'NOT_FOUND',
385
+ message: 'Member not found in this workspace'
386
+ });
387
+ }
388
+
389
+ // Update the member's role
390
+ const updatedMember = await ctx.db.workspaceMember.update({
391
+ where: { id: member.id },
392
+ data: { role: input.role },
393
+ include: {
394
+ user: {
395
+ select: {
396
+ id: true,
397
+ name: true,
398
+ email: true,
399
+ }
400
+ }
401
+ }
402
+ });
403
+
404
+ logger.info(`🔄 Member role changed for ${input.memberId} from ${member.role} to ${input.role} in workspace ${input.workspaceId}`, 'WORKSPACE', {
405
+ workspaceId: input.workspaceId,
406
+ memberId: input.memberId,
407
+ oldRole: member.role,
408
+ newRole: input.role,
409
+ changedBy: ctx.session.user.id
410
+ });
411
+
412
+ return {
413
+ memberId: input.memberId,
414
+ role: input.role,
415
+ memberName: updatedMember.user.name || updatedMember.user.email,
416
+ message: 'Role changed successfully'
417
+ };
418
+ }),
419
+
420
+ /**
421
+ * Remove a member from the workspace (owner only)
422
+ */
423
+ removeMember: authedProcedure
424
+ .input(z.object({
425
+ workspaceId: z.string(),
426
+ memberId: z.string(),
427
+ }))
428
+ .mutation(async ({ ctx, input }) => {
429
+ // Check if user is owner of the workspace
430
+ const workspace = await ctx.db.workspace.findFirst({
431
+ where: {
432
+ id: input.workspaceId,
433
+ ownerId: ctx.session.user.id
434
+ }
435
+ });
436
+
437
+ if (!workspace) {
438
+ throw new TRPCError({
439
+ code: 'NOT_FOUND',
440
+ message: 'Workspace not found or insufficient permissions'
441
+ });
442
+ }
443
+
444
+ // Check if trying to remove the owner
445
+ if (input.memberId === workspace.ownerId) {
446
+ throw new TRPCError({
447
+ code: 'BAD_REQUEST',
448
+ message: 'Cannot remove workspace owner'
449
+ });
450
+ }
451
+
452
+ // Check if member exists
453
+ const member = await ctx.db.workspaceMember.findFirst({
454
+ where: {
455
+ workspaceId: input.workspaceId,
456
+ userId: input.memberId
457
+ },
458
+ include: {
459
+ user: {
460
+ select: {
461
+ name: true,
462
+ email: true,
463
+ }
464
+ }
465
+ }
466
+ });
467
+
468
+ if (!member) {
469
+ throw new TRPCError({
470
+ code: 'NOT_FOUND',
471
+ message: 'Member not found in this workspace'
472
+ });
473
+ }
474
+
475
+ // Remove member from workspace
476
+ await ctx.db.workspaceMember.delete({
477
+ where: { id: member.id }
478
+ });
479
+
480
+ logger.info(`🗑️ Member ${input.memberId} removed from workspace ${input.workspaceId}`, 'WORKSPACE', {
481
+ workspaceId: input.workspaceId,
482
+ memberId: input.memberId,
483
+ removedBy: ctx.session.user.id
484
+ });
485
+
486
+ return {
487
+ memberId: input.memberId,
488
+ message: 'Member removed successfully'
489
+ };
490
+ }),
491
+
492
+ /**
493
+ * Get pending invitations for a workspace (owner only)
494
+ */
495
+ getPendingInvitations: authedProcedure
496
+ .input(z.object({
497
+ workspaceId: z.string(),
498
+ }))
499
+ .query(async ({ ctx, input }) => {
500
+ // Check if user is owner of the workspace
501
+ const workspace = await ctx.db.workspace.findFirst({
502
+ where: {
503
+ id: input.workspaceId,
504
+ ownerId: ctx.session.user.id
505
+ }
506
+ });
507
+
508
+ if (!workspace) {
509
+ throw new TRPCError({
510
+ code: 'NOT_FOUND',
511
+ message: 'Workspace not found or insufficient permissions'
512
+ });
513
+ }
514
+
515
+ const invitations = await ctx.db.workspaceInvitation.findMany({
516
+ where: {
517
+ workspaceId: input.workspaceId,
518
+ acceptedAt: null,
519
+ expiresAt: { gt: new Date() }
520
+ },
521
+ include: {
522
+ invitedBy: {
523
+ select: {
524
+ name: true,
525
+ email: true,
526
+ }
527
+ }
528
+ },
529
+ orderBy: { createdAt: 'desc' }
530
+ });
531
+
532
+ return invitations.map(invitation => ({
533
+ id: invitation.id,
534
+ email: invitation.email,
535
+ role: invitation.role,
536
+ token: invitation.token,
537
+ expiresAt: invitation.expiresAt,
538
+ createdAt: invitation.createdAt,
539
+ invitedByName: invitation.invitedBy.name || invitation.invitedBy.email,
540
+ }));
541
+ }),
542
+
543
+ /**
544
+ * Cancel a pending invitation (owner only)
545
+ */
546
+ cancelInvitation: authedProcedure
547
+ .input(z.object({
548
+ invitationId: z.string(),
549
+ }))
550
+ .mutation(async ({ ctx, input }) => {
551
+ // Check if user is owner of the workspace
552
+ const invitation = await ctx.db.workspaceInvitation.findFirst({
553
+ where: {
554
+ id: input.invitationId,
555
+ acceptedAt: null,
556
+ workspace: {
557
+ ownerId: ctx.session.user.id
558
+ }
559
+ }
560
+ });
561
+
562
+ if (!invitation) {
563
+ throw new TRPCError({
564
+ code: 'NOT_FOUND',
565
+ message: 'Invitation not found or insufficient permissions'
566
+ });
567
+ }
568
+
569
+ // Delete the invitation
570
+ await ctx.db.workspaceInvitation.delete({
571
+ where: { id: input.invitationId }
572
+ });
573
+
574
+ logger.info(`❌ Invitation cancelled for ${invitation.email} to workspace ${invitation.workspaceId}`, 'WORKSPACE', {
575
+ invitationId: input.invitationId,
576
+ workspaceId: invitation.workspaceId,
577
+ email: invitation.email,
578
+ cancelledBy: ctx.session.user.id
579
+ });
580
+
581
+ return {
582
+ invitationId: input.invitationId,
583
+ message: 'Invitation cancelled successfully'
584
+ };
585
+ }),
586
+ });