@goscribe/server 1.1.7 → 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/.env.example +43 -0
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/routers/auth.js +1 -1
- package/mcq-test.cjs +36 -0
- package/package.json +10 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +485 -292
- 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 +194 -112
- package/src/lib/constants.ts +14 -0
- package/src/lib/email.ts +230 -0
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +3 -3
- package/src/lib/logger.ts +26 -9
- 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 +90 -6
- package/src/lib/retry.ts +61 -0
- package/src/lib/storage.ts +2 -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/lib/workspace-access.ts +13 -0
- package/src/routers/_app.ts +11 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +227 -0
- package/src/routers/auth.ts +432 -33
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +207 -80
- 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 +133 -108
- package/src/routers/studyguide.ts +80 -74
- package/src/routers/worksheets.ts +300 -80
- package/src/routers/workspace.ts +538 -328
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +140 -12
- package/src/services/flashcard-progress.service.ts +52 -43
- 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/meetingsummary.ts +0 -416
package/src/routers/auth.ts
CHANGED
|
@@ -1,18 +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
|
-
import { supabaseClient } from '
|
|
9
|
+
import { supabaseClient } from '../lib/storage.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';
|
|
8
21
|
|
|
9
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
|
+
|
|
10
39
|
function createCustomAuthToken(userId: string): string {
|
|
11
40
|
const secret = process.env.AUTH_SECRET;
|
|
12
41
|
if (!secret) {
|
|
13
|
-
throw new
|
|
42
|
+
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: "AUTH_SECRET is not set" });
|
|
14
43
|
}
|
|
15
|
-
|
|
44
|
+
|
|
16
45
|
const base64UserId = Buffer.from(userId, 'utf8').toString('base64url');
|
|
17
46
|
const hmac = crypto.createHmac('sha256', secret);
|
|
18
47
|
hmac.update(base64UserId);
|
|
@@ -25,7 +54,7 @@ export const auth = router({
|
|
|
25
54
|
.input(z.object({
|
|
26
55
|
name: z.string().min(1),
|
|
27
56
|
}))
|
|
28
|
-
.mutation(async ({ctx, input}) => {
|
|
57
|
+
.mutation(async ({ ctx, input }) => {
|
|
29
58
|
const { name } = input;
|
|
30
59
|
|
|
31
60
|
await ctx.db.user.update({
|
|
@@ -42,19 +71,66 @@ export const auth = router({
|
|
|
42
71
|
message: 'Profile updated successfully',
|
|
43
72
|
};
|
|
44
73
|
}),
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
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}`;
|
|
48
122
|
const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
|
|
49
123
|
.from('media')
|
|
50
|
-
.createSignedUploadUrl(objectKey, { upsert: true });
|
|
124
|
+
.createSignedUploadUrl(objectKey, { upsert: true });
|
|
125
|
+
|
|
51
126
|
if (signedUrlError) {
|
|
127
|
+
logger.error(`Failed to generate upload URL: ${signedUrlError.message}`, 'AUTH');
|
|
52
128
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to generate upload URL: ${signedUrlError.message}` });
|
|
53
129
|
}
|
|
54
130
|
|
|
55
|
-
await ctx.db.fileAsset.create({
|
|
131
|
+
const fileAsset = await ctx.db.fileAsset.create({
|
|
56
132
|
data: {
|
|
57
|
-
userId:
|
|
133
|
+
userId: userId,
|
|
58
134
|
name: 'Profile Picture',
|
|
59
135
|
mimeType: 'image/jpeg',
|
|
60
136
|
size: 0,
|
|
@@ -63,39 +139,162 @@ export const auth = router({
|
|
|
63
139
|
},
|
|
64
140
|
});
|
|
65
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');
|
|
66
150
|
return {
|
|
67
151
|
success: true,
|
|
68
152
|
message: 'Profile picture uploaded successfully',
|
|
69
153
|
signedUrl: signedUrlData.signedUrl,
|
|
70
154
|
};
|
|
71
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
|
+
}),
|
|
72
162
|
signup: publicProcedure
|
|
73
163
|
.input(z.object({
|
|
74
164
|
name: z.string().min(1),
|
|
75
165
|
email: z.string().email(),
|
|
76
|
-
password:
|
|
166
|
+
password: passwordFieldSchema,
|
|
77
167
|
}))
|
|
78
168
|
.mutation(async ({ ctx, input }) => {
|
|
79
169
|
const existing = await ctx.db.user.findUnique({
|
|
80
170
|
where: { email: input.email },
|
|
81
171
|
});
|
|
82
172
|
if (existing) {
|
|
83
|
-
throw new
|
|
173
|
+
throw new TRPCError({ code: 'CONFLICT', message: "Email already registered" });
|
|
84
174
|
}
|
|
85
175
|
|
|
86
176
|
const hash = await bcrypt.hash(input.password, 10);
|
|
87
177
|
|
|
178
|
+
// Get default "User" role
|
|
179
|
+
const userRole = await ctx.db.role.findUnique({
|
|
180
|
+
where: { name: 'User' },
|
|
181
|
+
});
|
|
182
|
+
|
|
88
183
|
const user = await ctx.db.user.create({
|
|
89
184
|
data: {
|
|
90
185
|
name: input.name,
|
|
91
186
|
email: input.email,
|
|
92
187
|
passwordHash: hash,
|
|
93
|
-
|
|
188
|
+
roleId: userRole?.id,
|
|
189
|
+
// emailVerified is null -- user must verify
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await notifyAdminsOnSignup(ctx.db, {
|
|
194
|
+
id: user.id,
|
|
195
|
+
name: user.name,
|
|
196
|
+
email: user.email,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Create verification token (24h expiry)
|
|
200
|
+
const token = crypto.randomUUID();
|
|
201
|
+
await ctx.db.verificationToken.create({
|
|
202
|
+
data: {
|
|
203
|
+
identifier: input.email,
|
|
204
|
+
token,
|
|
205
|
+
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
94
206
|
},
|
|
95
207
|
});
|
|
96
208
|
|
|
209
|
+
// Send verification email (non-blocking)
|
|
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
|
+
});
|
|
221
|
+
|
|
97
222
|
return { id: user.id, email: user.email, name: user.name };
|
|
98
223
|
}),
|
|
224
|
+
|
|
225
|
+
// Verify email with token from the email link
|
|
226
|
+
verifyEmail: publicProcedure
|
|
227
|
+
.input(z.object({
|
|
228
|
+
token: z.string(),
|
|
229
|
+
}))
|
|
230
|
+
.mutation(async ({ ctx, input }) => {
|
|
231
|
+
const record = await ctx.db.verificationToken.findUnique({
|
|
232
|
+
where: { token: input.token },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!record) {
|
|
236
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired verification link' });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (record.expires < new Date()) {
|
|
240
|
+
// Clean up expired token
|
|
241
|
+
await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
|
|
242
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Verification link has expired. Please request a new one.' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Mark user as verified
|
|
246
|
+
await ctx.db.user.update({
|
|
247
|
+
where: { email: record.identifier },
|
|
248
|
+
data: { emailVerified: new Date() },
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Delete used token
|
|
252
|
+
await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
|
|
253
|
+
|
|
254
|
+
return { success: true, message: 'Email verified successfully' };
|
|
255
|
+
}),
|
|
256
|
+
|
|
257
|
+
// Resend verification email (for logged-in users who haven't verified)
|
|
258
|
+
resendVerification: publicProcedure
|
|
259
|
+
.mutation(async ({ ctx }) => {
|
|
260
|
+
if (!ctx.session?.user?.id) {
|
|
261
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not logged in' });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const user = await ctx.db.user.findUnique({
|
|
265
|
+
where: { id: ctx.session.user.id },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (!user || !user.email) {
|
|
269
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (user.emailVerified) {
|
|
273
|
+
return { success: true, message: 'Email is already verified' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Delete any existing tokens for this email
|
|
277
|
+
await ctx.db.verificationToken.deleteMany({
|
|
278
|
+
where: { identifier: user.email },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Create new token
|
|
282
|
+
const token = crypto.randomUUID();
|
|
283
|
+
await ctx.db.verificationToken.create({
|
|
284
|
+
data: {
|
|
285
|
+
identifier: user.email,
|
|
286
|
+
token,
|
|
287
|
+
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const sent = await sendVerificationEmail(user.email, token, user.name);
|
|
292
|
+
if (!sent) {
|
|
293
|
+
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send email. Please try again.' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { success: true, message: 'Verification email sent' };
|
|
297
|
+
}),
|
|
99
298
|
login: publicProcedure
|
|
100
299
|
.input(z.object({
|
|
101
300
|
email: z.string().email(),
|
|
@@ -106,12 +305,16 @@ export const auth = router({
|
|
|
106
305
|
where: { email: input.email },
|
|
107
306
|
});
|
|
108
307
|
if (!user) {
|
|
109
|
-
throw new
|
|
308
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (user.deletedAt) {
|
|
312
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "Account scheduled for deletion. Please check your email for a restore link." });
|
|
110
313
|
}
|
|
111
314
|
|
|
112
315
|
const valid = await bcrypt.compare(input.password, user.passwordHash!);
|
|
113
316
|
if (!valid) {
|
|
114
|
-
throw new
|
|
317
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
|
|
115
318
|
}
|
|
116
319
|
|
|
117
320
|
// Create custom auth token
|
|
@@ -127,49 +330,245 @@ export const auth = router({
|
|
|
127
330
|
domain: isProduction ? "server-w8mz.onrender.com" : undefined,
|
|
128
331
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
129
332
|
});
|
|
130
|
-
|
|
333
|
+
|
|
131
334
|
ctx.res.setHeader("Set-Cookie", cookieValue);
|
|
132
335
|
|
|
133
336
|
|
|
134
|
-
return {
|
|
135
|
-
id: user.id,
|
|
136
|
-
email: user.email,
|
|
137
|
-
name: user.name,
|
|
337
|
+
return {
|
|
338
|
+
id: user.id,
|
|
339
|
+
email: user.email,
|
|
340
|
+
name: user.name,
|
|
138
341
|
token: authToken
|
|
139
342
|
};
|
|
140
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
|
+
|
|
141
437
|
getSession: publicProcedure.query(async ({ ctx }) => {
|
|
142
438
|
// Just return the current session from context
|
|
143
439
|
if (!ctx.session) {
|
|
144
|
-
throw new
|
|
440
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "No session found" });
|
|
145
441
|
}
|
|
146
442
|
|
|
443
|
+
const userId = (ctx.session as any).user.id;
|
|
147
444
|
const user = await ctx.db.user.findUnique({
|
|
148
|
-
where: { id:
|
|
445
|
+
where: { id: userId },
|
|
446
|
+
include: {
|
|
447
|
+
profilePicture: true,
|
|
448
|
+
role: true,
|
|
449
|
+
}
|
|
149
450
|
});
|
|
150
451
|
|
|
151
452
|
if (!user) {
|
|
152
|
-
throw new
|
|
453
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: "User not found" });
|
|
153
454
|
}
|
|
154
455
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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,
|
|
467
|
+
emailVerified: !!user.emailVerified,
|
|
468
|
+
profilePicture: profilePictureUrl,
|
|
469
|
+
role: user.role,
|
|
470
|
+
}
|
|
161
471
|
};
|
|
162
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
|
+
|
|
163
562
|
logout: publicProcedure.mutation(async ({ ctx }) => {
|
|
164
563
|
const token = ctx.cookies["auth_token"];
|
|
165
564
|
|
|
166
565
|
if (!token) {
|
|
167
|
-
throw new
|
|
566
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "No token found" });
|
|
168
567
|
}
|
|
169
568
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
569
|
+
// We don't need to delete from db.session because we use a stateless
|
|
570
|
+
// custom HMAC auth system (auth_token cookie).
|
|
571
|
+
|
|
173
572
|
|
|
174
573
|
ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
|
|
175
574
|
httpOnly: true,
|