@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.
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/mcq-test.cjs +36 -0
- package/package.json +9 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +471 -324
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +79 -51
- package/src/lib/email.ts +213 -29
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +2 -2
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +86 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/routers/_app.ts +9 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +41 -0
- package/src/routers/auth.ts +338 -28
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +201 -68
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +112 -83
- package/src/routers/studyguide.ts +12 -0
- package/src/routers/worksheets.ts +289 -66
- package/src/routers/workspace.ts +329 -122
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +137 -11
- package/src/services/flashcard-progress.service.ts +49 -37
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
package/src/routers/members.ts
CHANGED
|
@@ -2,6 +2,14 @@ import { z } from 'zod';
|
|
|
2
2
|
import { TRPCError } from '@trpc/server';
|
|
3
3
|
import { router, publicProcedure, authedProcedure } from '../trpc.js';
|
|
4
4
|
import { logger } from '../lib/logger.js';
|
|
5
|
+
import { sendInvitationEmail } from '../lib/email.js';
|
|
6
|
+
import PusherService from '../lib/pusher.js';
|
|
7
|
+
import {
|
|
8
|
+
notifyInviteAccepted,
|
|
9
|
+
notifyInviteRecipient,
|
|
10
|
+
notifyWorkspaceMembershipRemoved,
|
|
11
|
+
notifyWorkspaceRoleChanged,
|
|
12
|
+
} from '../lib/notification-service.js';
|
|
5
13
|
|
|
6
14
|
/**
|
|
7
15
|
* Members router for workspace member management
|
|
@@ -69,14 +77,13 @@ export const members = router({
|
|
|
69
77
|
});
|
|
70
78
|
|
|
71
79
|
if (!workspace) {
|
|
72
|
-
throw new TRPCError({
|
|
73
|
-
code: 'NOT_FOUND',
|
|
74
|
-
message: 'Workspace not found or access denied'
|
|
80
|
+
throw new TRPCError({
|
|
81
|
+
code: 'NOT_FOUND',
|
|
82
|
+
message: 'Workspace not found or access denied'
|
|
75
83
|
});
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
const members = [
|
|
86
|
+
const workspaceMembers = [
|
|
80
87
|
{
|
|
81
88
|
id: workspace.owner.id,
|
|
82
89
|
name: workspace.owner.name || 'Unknown',
|
|
@@ -93,7 +100,11 @@ export const members = router({
|
|
|
93
100
|
}))
|
|
94
101
|
];
|
|
95
102
|
|
|
96
|
-
|
|
103
|
+
logger.info(`👥 Fetched ${workspaceMembers.length} members for workspace ${input.workspaceId}`, 'WORKSPACE', {
|
|
104
|
+
memberEmails: workspaceMembers.map(m => m.email)
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return workspaceMembers;
|
|
97
108
|
}),
|
|
98
109
|
|
|
99
110
|
/**
|
|
@@ -116,9 +127,9 @@ export const members = router({
|
|
|
116
127
|
});
|
|
117
128
|
|
|
118
129
|
if (!workspace) {
|
|
119
|
-
throw new TRPCError({
|
|
120
|
-
code: 'NOT_FOUND',
|
|
121
|
-
message: 'Workspace not found'
|
|
130
|
+
throw new TRPCError({
|
|
131
|
+
code: 'NOT_FOUND',
|
|
132
|
+
message: 'Workspace not found'
|
|
122
133
|
});
|
|
123
134
|
}
|
|
124
135
|
|
|
@@ -130,9 +141,9 @@ export const members = router({
|
|
|
130
141
|
return workspace.members[0].role as 'admin' | 'member';
|
|
131
142
|
}
|
|
132
143
|
|
|
133
|
-
throw new TRPCError({
|
|
134
|
-
code: 'FORBIDDEN',
|
|
135
|
-
message: 'Access denied to this workspace'
|
|
144
|
+
throw new TRPCError({
|
|
145
|
+
code: 'FORBIDDEN',
|
|
146
|
+
message: 'Access denied to this workspace'
|
|
136
147
|
});
|
|
137
148
|
}),
|
|
138
149
|
|
|
@@ -148,22 +159,22 @@ export const members = router({
|
|
|
148
159
|
.mutation(async ({ ctx, input }) => {
|
|
149
160
|
// Check if user is owner or admin of the workspace
|
|
150
161
|
const workspace = await ctx.db.workspace.findFirst({
|
|
151
|
-
where: {
|
|
162
|
+
where: {
|
|
152
163
|
id: input.workspaceId,
|
|
153
164
|
ownerId: ctx.session.user.id // Only owners can invite for now
|
|
154
165
|
}
|
|
155
166
|
});
|
|
156
167
|
|
|
157
168
|
if (!workspace) {
|
|
158
|
-
throw new TRPCError({
|
|
159
|
-
code: 'NOT_FOUND',
|
|
160
|
-
message: 'Workspace not found or insufficient permissions'
|
|
169
|
+
throw new TRPCError({
|
|
170
|
+
code: 'NOT_FOUND',
|
|
171
|
+
message: 'Workspace not found or insufficient permissions'
|
|
161
172
|
});
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
// Check if user is already a member
|
|
165
176
|
const existingMember = await ctx.db.user.findFirst({
|
|
166
|
-
where: {
|
|
177
|
+
where: {
|
|
167
178
|
email: input.email,
|
|
168
179
|
OR: [
|
|
169
180
|
{ id: workspace.ownerId },
|
|
@@ -173,9 +184,9 @@ export const members = router({
|
|
|
173
184
|
});
|
|
174
185
|
|
|
175
186
|
if (existingMember) {
|
|
176
|
-
throw new TRPCError({
|
|
177
|
-
code: 'BAD_REQUEST',
|
|
178
|
-
message: 'User is already a member of this workspace'
|
|
187
|
+
throw new TRPCError({
|
|
188
|
+
code: 'BAD_REQUEST',
|
|
189
|
+
message: 'User is already a member of this workspace'
|
|
179
190
|
});
|
|
180
191
|
}
|
|
181
192
|
|
|
@@ -190,9 +201,9 @@ export const members = router({
|
|
|
190
201
|
});
|
|
191
202
|
|
|
192
203
|
if (existingInvitation) {
|
|
193
|
-
throw new TRPCError({
|
|
194
|
-
code: 'BAD_REQUEST',
|
|
195
|
-
message: 'Invitation already sent to this email'
|
|
204
|
+
throw new TRPCError({
|
|
205
|
+
code: 'BAD_REQUEST',
|
|
206
|
+
message: 'Invitation already sent to this email'
|
|
196
207
|
});
|
|
197
208
|
}
|
|
198
209
|
|
|
@@ -219,6 +230,23 @@ export const members = router({
|
|
|
219
230
|
}
|
|
220
231
|
});
|
|
221
232
|
|
|
233
|
+
const invitedExistingUser = await ctx.db.user.findUnique({
|
|
234
|
+
where: { email: input.email },
|
|
235
|
+
select: { id: true, name: true },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (invitedExistingUser) {
|
|
239
|
+
await notifyInviteRecipient(ctx.db, {
|
|
240
|
+
invitedUserId: invitedExistingUser.id,
|
|
241
|
+
inviterUserId: ctx.session.user.id,
|
|
242
|
+
workspaceId: input.workspaceId,
|
|
243
|
+
workspaceTitle: invitation.workspace.title,
|
|
244
|
+
invitationId: invitation.id,
|
|
245
|
+
invitationToken: invitation.token,
|
|
246
|
+
inviterName: invitation.workspace.owner.name || invitation.workspace.owner.email,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
222
250
|
logger.info(`🎫 Invitation created for ${input.email} to workspace ${input.workspaceId} with role ${input.role}`, 'WORKSPACE', {
|
|
223
251
|
invitationId: invitation.id,
|
|
224
252
|
workspaceId: input.workspaceId,
|
|
@@ -227,8 +255,14 @@ export const members = router({
|
|
|
227
255
|
invitedBy: ctx.session.user.id
|
|
228
256
|
});
|
|
229
257
|
|
|
230
|
-
//
|
|
231
|
-
|
|
258
|
+
// Send email notification
|
|
259
|
+
await sendInvitationEmail({
|
|
260
|
+
email: invitation.email,
|
|
261
|
+
token: invitation.token,
|
|
262
|
+
role: invitation.role,
|
|
263
|
+
workspaceTitle: invitation.workspace.title,
|
|
264
|
+
invitedByName: invitation.workspace.owner.name || invitation.workspace.owner.email || 'Someone',
|
|
265
|
+
});
|
|
232
266
|
|
|
233
267
|
return {
|
|
234
268
|
invitationId: invitation.id,
|
|
@@ -270,6 +304,7 @@ export const members = router({
|
|
|
270
304
|
select: {
|
|
271
305
|
id: true,
|
|
272
306
|
title: true,
|
|
307
|
+
ownerId: true,
|
|
273
308
|
owner: {
|
|
274
309
|
select: {
|
|
275
310
|
name: true,
|
|
@@ -282,27 +317,31 @@ export const members = router({
|
|
|
282
317
|
});
|
|
283
318
|
|
|
284
319
|
if (!invitation) {
|
|
285
|
-
throw new TRPCError({
|
|
286
|
-
code: 'NOT_FOUND',
|
|
287
|
-
message: 'Invalid or expired invitation'
|
|
320
|
+
throw new TRPCError({
|
|
321
|
+
code: 'NOT_FOUND',
|
|
322
|
+
message: 'Invalid or expired invitation'
|
|
288
323
|
});
|
|
289
324
|
}
|
|
290
325
|
|
|
291
326
|
// Check if user is authenticated
|
|
292
327
|
if (!ctx.session?.user) {
|
|
293
|
-
throw new TRPCError({
|
|
294
|
-
code: 'UNAUTHORIZED',
|
|
295
|
-
message: 'Please log in to accept this invitation'
|
|
328
|
+
throw new TRPCError({
|
|
329
|
+
code: 'UNAUTHORIZED',
|
|
330
|
+
message: 'Please log in to accept this invitation'
|
|
296
331
|
});
|
|
297
332
|
}
|
|
298
333
|
|
|
299
334
|
const user = await ctx.db.user.findFirst({ where: { id: ctx.session.user.id } });
|
|
300
335
|
if (!user || !user.email) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
336
|
+
|
|
337
|
+
logger.info(`🔍 Verification check for ${user.email} accepting invite for ${invitation.email}`, 'WORKSPACE');
|
|
338
|
+
|
|
339
|
+
// Check if the email matches the user's email (case-insensitive)
|
|
340
|
+
if (user.email.toLowerCase() !== invitation.email.toLowerCase()) {
|
|
341
|
+
logger.warn(`❌ Invitation email mismatch: user ${user.email} vs invite ${invitation.email}`, 'WORKSPACE');
|
|
342
|
+
throw new TRPCError({
|
|
343
|
+
code: 'BAD_REQUEST',
|
|
344
|
+
message: 'This invitation was sent to a different email address'
|
|
306
345
|
});
|
|
307
346
|
}
|
|
308
347
|
|
|
@@ -318,26 +357,41 @@ export const members = router({
|
|
|
318
357
|
});
|
|
319
358
|
|
|
320
359
|
if (isAlreadyMember) {
|
|
360
|
+
logger.info(`ℹ️ User ${ctx.session.user.id} is already a member of workspace ${invitation.workspaceId}. Marking invite as accepted.`, 'WORKSPACE');
|
|
321
361
|
// Mark invitation as accepted even if already a member
|
|
322
362
|
await ctx.db.workspaceInvitation.update({
|
|
323
363
|
where: { id: invitation.id },
|
|
324
364
|
data: { acceptedAt: new Date() }
|
|
325
365
|
});
|
|
326
366
|
|
|
327
|
-
|
|
328
|
-
code: 'BAD_REQUEST',
|
|
329
|
-
message: 'You are already a member of this workspace'
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Add user to workspace with proper role
|
|
334
|
-
await ctx.db.workspaceMember.create({
|
|
335
|
-
data: {
|
|
367
|
+
return {
|
|
336
368
|
workspaceId: invitation.workspaceId,
|
|
337
|
-
|
|
369
|
+
workspaceTitle: invitation.workspace.title,
|
|
338
370
|
role: invitation.role,
|
|
371
|
+
ownerName: invitation.workspace.owner.name || invitation.workspace.owner.email,
|
|
372
|
+
message: 'You are already a member of this workspace'
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Add user to workspace with proper role.
|
|
377
|
+
// This can race if accept is triggered twice (e.g. redirects/retries),
|
|
378
|
+
// so treat duplicate membership as a successful, idempotent accept.
|
|
379
|
+
let memberAdded = false;
|
|
380
|
+
try {
|
|
381
|
+
await ctx.db.workspaceMember.create({
|
|
382
|
+
data: {
|
|
383
|
+
workspaceId: invitation.workspaceId,
|
|
384
|
+
userId: ctx.session.user.id,
|
|
385
|
+
role: invitation.role,
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
memberAdded = true;
|
|
389
|
+
} catch (error: any) {
|
|
390
|
+
if (error?.code !== 'P2002') {
|
|
391
|
+
throw error;
|
|
339
392
|
}
|
|
340
|
-
|
|
393
|
+
logger.info(`ℹ️ Duplicate invite accept handled for user ${ctx.session.user.id} in workspace ${invitation.workspaceId}`, 'WORKSPACE');
|
|
394
|
+
}
|
|
341
395
|
|
|
342
396
|
// Mark invitation as accepted
|
|
343
397
|
await ctx.db.workspaceInvitation.update({
|
|
@@ -345,18 +399,44 @@ export const members = router({
|
|
|
345
399
|
data: { acceptedAt: new Date() }
|
|
346
400
|
});
|
|
347
401
|
|
|
348
|
-
|
|
402
|
+
if (memberAdded) {
|
|
403
|
+
await notifyInviteAccepted(ctx.db, {
|
|
404
|
+
recipientUserIds: [invitation.invitedById, invitation.workspace.ownerId],
|
|
405
|
+
actorUserId: ctx.session.user.id,
|
|
406
|
+
workspaceId: invitation.workspaceId,
|
|
407
|
+
workspaceTitle: invitation.workspace.title,
|
|
408
|
+
memberName: user.name || user.email,
|
|
409
|
+
invitationId: invitation.id,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
logger.info(`✅ Invitation accepted by ${ctx.session.user.id} (${user.email}) for workspace ${invitation.workspaceId}`, 'WORKSPACE', {
|
|
349
414
|
invitationId: invitation.id,
|
|
350
415
|
workspaceId: invitation.workspaceId,
|
|
351
416
|
userId: ctx.session.user.id,
|
|
352
417
|
email: invitation.email
|
|
353
418
|
});
|
|
354
419
|
|
|
420
|
+
// Try to emit a Pusher event if possible
|
|
421
|
+
try {
|
|
422
|
+
if (memberAdded) {
|
|
423
|
+
await PusherService.emitMemberJoined(invitation.workspaceId, {
|
|
424
|
+
id: ctx.session.user.id,
|
|
425
|
+
name: user.name || 'Member',
|
|
426
|
+
email: user.email,
|
|
427
|
+
role: invitation.role,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
} catch (e) {
|
|
431
|
+
logger.error('Failed to emit member joined event', e);
|
|
432
|
+
}
|
|
433
|
+
|
|
355
434
|
return {
|
|
356
435
|
workspaceId: invitation.workspaceId,
|
|
357
436
|
workspaceTitle: invitation.workspace.title,
|
|
358
437
|
role: invitation.role,
|
|
359
438
|
ownerName: invitation.workspace.owner.name || invitation.workspace.owner.email,
|
|
439
|
+
...(memberAdded ? {} : { message: 'You are already a member of this workspace' }),
|
|
360
440
|
};
|
|
361
441
|
}),
|
|
362
442
|
|
|
@@ -372,38 +452,39 @@ export const members = router({
|
|
|
372
452
|
.mutation(async ({ ctx, input }) => {
|
|
373
453
|
// Check if user is owner of the workspace
|
|
374
454
|
const workspace = await ctx.db.workspace.findFirst({
|
|
375
|
-
where: {
|
|
455
|
+
where: {
|
|
376
456
|
id: input.workspaceId,
|
|
377
457
|
ownerId: ctx.session.user.id
|
|
378
|
-
}
|
|
458
|
+
},
|
|
459
|
+
select: { id: true, title: true, ownerId: true },
|
|
379
460
|
});
|
|
380
461
|
|
|
381
462
|
if (!workspace) {
|
|
382
|
-
throw new TRPCError({
|
|
383
|
-
code: 'NOT_FOUND',
|
|
384
|
-
message: 'Workspace not found or insufficient permissions'
|
|
463
|
+
throw new TRPCError({
|
|
464
|
+
code: 'NOT_FOUND',
|
|
465
|
+
message: 'Workspace not found or insufficient permissions'
|
|
385
466
|
});
|
|
386
467
|
}
|
|
387
468
|
|
|
388
469
|
// Check if member exists and is not the owner
|
|
389
470
|
if (input.memberId === workspace.ownerId) {
|
|
390
|
-
throw new TRPCError({
|
|
391
|
-
code: 'BAD_REQUEST',
|
|
392
|
-
message: 'Cannot change owner role'
|
|
471
|
+
throw new TRPCError({
|
|
472
|
+
code: 'BAD_REQUEST',
|
|
473
|
+
message: 'Cannot change owner role'
|
|
393
474
|
});
|
|
394
475
|
}
|
|
395
476
|
|
|
396
477
|
const member = await ctx.db.workspaceMember.findFirst({
|
|
397
|
-
where: {
|
|
478
|
+
where: {
|
|
398
479
|
workspaceId: input.workspaceId,
|
|
399
480
|
userId: input.memberId
|
|
400
481
|
}
|
|
401
482
|
});
|
|
402
483
|
|
|
403
484
|
if (!member) {
|
|
404
|
-
throw new TRPCError({
|
|
405
|
-
code: 'NOT_FOUND',
|
|
406
|
-
message: 'Member not found in this workspace'
|
|
485
|
+
throw new TRPCError({
|
|
486
|
+
code: 'NOT_FOUND',
|
|
487
|
+
message: 'Member not found in this workspace'
|
|
407
488
|
});
|
|
408
489
|
}
|
|
409
490
|
|
|
@@ -430,6 +511,20 @@ export const members = router({
|
|
|
430
511
|
changedBy: ctx.session.user.id
|
|
431
512
|
});
|
|
432
513
|
|
|
514
|
+
const actor = await ctx.db.user.findUnique({
|
|
515
|
+
where: { id: ctx.session.user.id },
|
|
516
|
+
select: { name: true, email: true },
|
|
517
|
+
});
|
|
518
|
+
await notifyWorkspaceRoleChanged(ctx.db, {
|
|
519
|
+
memberUserId: input.memberId,
|
|
520
|
+
workspaceId: input.workspaceId,
|
|
521
|
+
workspaceTitle: workspace.title,
|
|
522
|
+
newRole: input.role,
|
|
523
|
+
oldRole: member.role,
|
|
524
|
+
actorUserId: ctx.session.user.id,
|
|
525
|
+
actorName: actor?.name || actor?.email || 'A workspace admin',
|
|
526
|
+
}).catch(() => {});
|
|
527
|
+
|
|
433
528
|
return {
|
|
434
529
|
memberId: input.memberId,
|
|
435
530
|
role: input.role,
|
|
@@ -449,30 +544,31 @@ export const members = router({
|
|
|
449
544
|
.mutation(async ({ ctx, input }) => {
|
|
450
545
|
// Check if user is owner of the workspace
|
|
451
546
|
const workspace = await ctx.db.workspace.findFirst({
|
|
452
|
-
where: {
|
|
547
|
+
where: {
|
|
453
548
|
id: input.workspaceId,
|
|
454
549
|
ownerId: ctx.session.user.id
|
|
455
|
-
}
|
|
550
|
+
},
|
|
551
|
+
select: { id: true, title: true, ownerId: true },
|
|
456
552
|
});
|
|
457
553
|
|
|
458
554
|
if (!workspace) {
|
|
459
|
-
throw new TRPCError({
|
|
460
|
-
code: 'NOT_FOUND',
|
|
461
|
-
message: 'Workspace not found or insufficient permissions'
|
|
555
|
+
throw new TRPCError({
|
|
556
|
+
code: 'NOT_FOUND',
|
|
557
|
+
message: 'Workspace not found or insufficient permissions'
|
|
462
558
|
});
|
|
463
559
|
}
|
|
464
560
|
|
|
465
561
|
// Check if trying to remove the owner
|
|
466
562
|
if (input.memberId === workspace.ownerId) {
|
|
467
|
-
throw new TRPCError({
|
|
468
|
-
code: 'BAD_REQUEST',
|
|
469
|
-
message: 'Cannot remove workspace owner'
|
|
563
|
+
throw new TRPCError({
|
|
564
|
+
code: 'BAD_REQUEST',
|
|
565
|
+
message: 'Cannot remove workspace owner'
|
|
470
566
|
});
|
|
471
567
|
}
|
|
472
568
|
|
|
473
569
|
// Check if member exists
|
|
474
570
|
const member = await ctx.db.workspaceMember.findFirst({
|
|
475
|
-
where: {
|
|
571
|
+
where: {
|
|
476
572
|
workspaceId: input.workspaceId,
|
|
477
573
|
userId: input.memberId
|
|
478
574
|
},
|
|
@@ -487,12 +583,24 @@ export const members = router({
|
|
|
487
583
|
});
|
|
488
584
|
|
|
489
585
|
if (!member) {
|
|
490
|
-
throw new TRPCError({
|
|
491
|
-
code: 'NOT_FOUND',
|
|
492
|
-
message: 'Member not found in this workspace'
|
|
586
|
+
throw new TRPCError({
|
|
587
|
+
code: 'NOT_FOUND',
|
|
588
|
+
message: 'Member not found in this workspace'
|
|
493
589
|
});
|
|
494
590
|
}
|
|
495
591
|
|
|
592
|
+
const actor = await ctx.db.user.findUnique({
|
|
593
|
+
where: { id: ctx.session.user.id },
|
|
594
|
+
select: { name: true, email: true },
|
|
595
|
+
});
|
|
596
|
+
await notifyWorkspaceMembershipRemoved(ctx.db, {
|
|
597
|
+
memberUserId: input.memberId,
|
|
598
|
+
workspaceId: input.workspaceId,
|
|
599
|
+
workspaceTitle: workspace.title,
|
|
600
|
+
actorUserId: ctx.session.user.id,
|
|
601
|
+
actorName: actor?.name || actor?.email || 'A workspace admin',
|
|
602
|
+
}).catch(() => {});
|
|
603
|
+
|
|
496
604
|
// Remove member from workspace
|
|
497
605
|
await ctx.db.workspaceMember.delete({
|
|
498
606
|
where: { id: member.id }
|
|
@@ -520,16 +628,16 @@ export const members = router({
|
|
|
520
628
|
.query(async ({ ctx, input }) => {
|
|
521
629
|
// Check if user is owner of the workspace
|
|
522
630
|
const workspace = await ctx.db.workspace.findFirst({
|
|
523
|
-
where: {
|
|
631
|
+
where: {
|
|
524
632
|
id: input.workspaceId,
|
|
525
633
|
ownerId: ctx.session.user.id
|
|
526
634
|
}
|
|
527
635
|
});
|
|
528
636
|
|
|
529
637
|
if (!workspace) {
|
|
530
|
-
throw new TRPCError({
|
|
531
|
-
code: 'NOT_FOUND',
|
|
532
|
-
message: 'Workspace not found or insufficient permissions'
|
|
638
|
+
throw new TRPCError({
|
|
639
|
+
code: 'NOT_FOUND',
|
|
640
|
+
message: 'Workspace not found or insufficient permissions'
|
|
533
641
|
});
|
|
534
642
|
}
|
|
535
643
|
|
|
@@ -571,7 +679,7 @@ export const members = router({
|
|
|
571
679
|
.mutation(async ({ ctx, input }) => {
|
|
572
680
|
// Check if user is owner of the workspace
|
|
573
681
|
const invitation = await ctx.db.workspaceInvitation.findFirst({
|
|
574
|
-
where: {
|
|
682
|
+
where: {
|
|
575
683
|
id: input.invitationId,
|
|
576
684
|
acceptedAt: null,
|
|
577
685
|
workspace: {
|
|
@@ -581,9 +689,9 @@ export const members = router({
|
|
|
581
689
|
});
|
|
582
690
|
|
|
583
691
|
if (!invitation) {
|
|
584
|
-
throw new TRPCError({
|
|
585
|
-
code: 'NOT_FOUND',
|
|
586
|
-
message: 'Invitation not found or insufficient permissions'
|
|
692
|
+
throw new TRPCError({
|
|
693
|
+
code: 'NOT_FOUND',
|
|
694
|
+
message: 'Invitation not found or insufficient permissions'
|
|
587
695
|
});
|
|
588
696
|
}
|
|
589
697
|
|
|
@@ -604,4 +712,96 @@ export const members = router({
|
|
|
604
712
|
message: 'Invitation cancelled successfully'
|
|
605
713
|
};
|
|
606
714
|
}),
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Resend a pending invitation (owner only)
|
|
718
|
+
*/
|
|
719
|
+
resendInvitation: authedProcedure
|
|
720
|
+
.input(z.object({
|
|
721
|
+
invitationId: z.string(),
|
|
722
|
+
}))
|
|
723
|
+
.mutation(async ({ ctx, input }) => {
|
|
724
|
+
// Check if user is owner of the workspace and invitation is pending
|
|
725
|
+
const invitation = await ctx.db.workspaceInvitation.findFirst({
|
|
726
|
+
where: {
|
|
727
|
+
id: input.invitationId,
|
|
728
|
+
acceptedAt: null,
|
|
729
|
+
workspace: {
|
|
730
|
+
ownerId: ctx.session.user.id
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
include: {
|
|
734
|
+
workspace: {
|
|
735
|
+
select: {
|
|
736
|
+
title: true,
|
|
737
|
+
owner: {
|
|
738
|
+
select: {
|
|
739
|
+
name: true,
|
|
740
|
+
email: true,
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
if (!invitation) {
|
|
749
|
+
throw new TRPCError({
|
|
750
|
+
code: 'NOT_FOUND',
|
|
751
|
+
message: 'Invitation not found or insufficient permissions'
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Check if expired and update expiry if needed
|
|
756
|
+
if (invitation.expiresAt < new Date()) {
|
|
757
|
+
const newExpiry = new Date();
|
|
758
|
+
newExpiry.setDate(newExpiry.getDate() + 7);
|
|
759
|
+
await ctx.db.workspaceInvitation.update({
|
|
760
|
+
where: { id: invitation.id },
|
|
761
|
+
data: { expiresAt: newExpiry }
|
|
762
|
+
});
|
|
763
|
+
invitation.expiresAt = newExpiry;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Send email notification
|
|
767
|
+
await sendInvitationEmail({
|
|
768
|
+
email: invitation.email,
|
|
769
|
+
token: invitation.token,
|
|
770
|
+
role: invitation.role,
|
|
771
|
+
workspaceTitle: invitation.workspace.title,
|
|
772
|
+
invitedByName: invitation.workspace.owner.name || invitation.workspace.owner.email || 'Someone',
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
logger.info(`📧 Invitation resent to ${invitation.email} for workspace ${invitation.workspaceId}`, 'WORKSPACE', {
|
|
776
|
+
invitationId: invitation.id,
|
|
777
|
+
workspaceId: invitation.workspaceId,
|
|
778
|
+
email: invitation.email,
|
|
779
|
+
resentBy: ctx.session.user.id
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
invitationId: invitation.id,
|
|
784
|
+
message: 'Invitation email resent successfully'
|
|
785
|
+
};
|
|
786
|
+
}),
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* DEBUG ONLY: Get all invitations for a workspace
|
|
790
|
+
*/
|
|
791
|
+
getAllInvitationsDebug: authedProcedure
|
|
792
|
+
.input(z.object({
|
|
793
|
+
workspaceId: z.string(),
|
|
794
|
+
}))
|
|
795
|
+
.query(async ({ ctx, input }) => {
|
|
796
|
+
// Check if user is owner
|
|
797
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
798
|
+
where: { id: input.workspaceId, ownerId: ctx.session.user.id }
|
|
799
|
+
});
|
|
800
|
+
if (!workspace) throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
801
|
+
|
|
802
|
+
return ctx.db.workspaceInvitation.findMany({
|
|
803
|
+
where: { workspaceId: input.workspaceId },
|
|
804
|
+
orderBy: { createdAt: 'desc' }
|
|
805
|
+
});
|
|
806
|
+
}),
|
|
607
807
|
});
|