@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
|
@@ -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
|
|
package/src/routers/auth.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
47
|
-
.
|
|
48
|
-
|
|
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 });
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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,
|