@goscribe/server 1.2.0 → 1.3.1
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/dist/context.d.ts +5 -1
- package/dist/lib/activity_human_description.d.ts +13 -0
- package/dist/lib/activity_human_description.js +221 -0
- package/dist/lib/activity_human_description.test.d.ts +1 -0
- package/dist/lib/activity_human_description.test.js +16 -0
- package/dist/lib/activity_log_service.d.ts +87 -0
- package/dist/lib/activity_log_service.js +276 -0
- package/dist/lib/activity_log_service.test.d.ts +1 -0
- package/dist/lib/activity_log_service.test.js +27 -0
- package/dist/lib/ai-session.d.ts +15 -2
- package/dist/lib/ai-session.js +147 -85
- package/dist/lib/constants.d.ts +13 -0
- package/dist/lib/constants.js +12 -0
- package/dist/lib/email.d.ts +11 -0
- package/dist/lib/email.js +193 -0
- package/dist/lib/env.d.ts +13 -0
- package/dist/lib/env.js +16 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +3 -3
- package/dist/lib/logger.d.ts +4 -4
- package/dist/lib/logger.js +30 -8
- package/dist/lib/notification-service.d.ts +152 -0
- package/dist/lib/notification-service.js +473 -0
- package/dist/lib/notification-service.test.d.ts +1 -0
- package/dist/lib/notification-service.test.js +87 -0
- package/dist/lib/prisma.d.ts +2 -1
- package/dist/lib/prisma.js +5 -1
- package/dist/lib/pusher.d.ts +23 -0
- package/dist/lib/pusher.js +69 -5
- package/dist/lib/retry.d.ts +15 -0
- package/dist/lib/retry.js +37 -0
- package/dist/lib/storage.js +2 -2
- package/dist/lib/stripe.d.ts +9 -0
- package/dist/lib/stripe.js +36 -0
- package/dist/lib/subscription_service.d.ts +37 -0
- package/dist/lib/subscription_service.js +654 -0
- package/dist/lib/usage_service.d.ts +26 -0
- package/dist/lib/usage_service.js +59 -0
- package/dist/lib/worksheet-generation.d.ts +91 -0
- package/dist/lib/worksheet-generation.js +95 -0
- package/dist/lib/worksheet-generation.test.d.ts +1 -0
- package/dist/lib/worksheet-generation.test.js +20 -0
- package/dist/lib/workspace-access.d.ts +18 -0
- package/dist/lib/workspace-access.js +13 -0
- package/dist/routers/_app.d.ts +1349 -253
- package/dist/routers/_app.js +10 -0
- package/dist/routers/admin.d.ts +361 -0
- package/dist/routers/admin.js +633 -0
- package/dist/routers/annotations.d.ts +219 -0
- package/dist/routers/annotations.js +187 -0
- package/dist/routers/auth.d.ts +88 -7
- package/dist/routers/auth.js +339 -19
- package/dist/routers/chat.d.ts +6 -12
- package/dist/routers/copilot.d.ts +199 -0
- package/dist/routers/copilot.js +571 -0
- package/dist/routers/flashcards.d.ts +47 -81
- package/dist/routers/flashcards.js +143 -27
- package/dist/routers/members.d.ts +36 -7
- package/dist/routers/members.js +200 -19
- package/dist/routers/notifications.d.ts +99 -0
- package/dist/routers/notifications.js +127 -0
- package/dist/routers/payment.d.ts +89 -0
- package/dist/routers/payment.js +403 -0
- package/dist/routers/podcast.d.ts +8 -13
- package/dist/routers/podcast.js +54 -31
- package/dist/routers/studyguide.d.ts +1 -29
- package/dist/routers/studyguide.js +80 -71
- package/dist/routers/worksheets.d.ts +105 -38
- package/dist/routers/worksheets.js +258 -68
- package/dist/routers/workspace.d.ts +139 -60
- package/dist/routers/workspace.js +455 -315
- package/dist/scripts/purge-deleted-users.d.ts +1 -0
- package/dist/scripts/purge-deleted-users.js +149 -0
- package/dist/server.js +130 -10
- package/dist/services/flashcard-progress.service.d.ts +18 -66
- package/dist/services/flashcard-progress.service.js +51 -42
- package/dist/trpc.d.ts +20 -21
- package/dist/trpc.js +150 -1
- 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/dist/routers/auth.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { router, publicProcedure } from '../trpc.js';
|
|
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';
|
|
10
|
+
import { sendVerificationEmail, sendAccountDeletionScheduledEmail, sendAccountRestoredEmail, sendPasswordResetEmail, } from '../lib/email.js';
|
|
11
|
+
import { createStripeCustomer } from '../lib/stripe.js';
|
|
12
|
+
import { notifyAdminsAccountDeletionScheduled, notifyAdminsOnSignup, } from '../lib/notification-service.js';
|
|
8
13
|
// Helper to create custom auth token
|
|
14
|
+
const passwordFieldSchema = z
|
|
15
|
+
.string()
|
|
16
|
+
.min(8, 'Password must be at least 8 characters')
|
|
17
|
+
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
18
|
+
.regex(/[0-9]/, 'Password must contain at least one number')
|
|
19
|
+
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character');
|
|
20
|
+
function hashPasswordResetToken(rawToken) {
|
|
21
|
+
return crypto.createHash('sha256').update(rawToken, 'utf8').digest('hex');
|
|
22
|
+
}
|
|
23
|
+
/** Use until `npx prisma generate` runs after adding PasswordResetToken to the schema. */
|
|
24
|
+
function passwordResetDb(ctx) {
|
|
25
|
+
return ctx.db.passwordResetToken;
|
|
26
|
+
}
|
|
9
27
|
function createCustomAuthToken(userId) {
|
|
10
28
|
const secret = process.env.AUTH_SECRET;
|
|
11
29
|
if (!secret) {
|
|
12
|
-
throw new
|
|
30
|
+
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: "AUTH_SECRET is not set" });
|
|
13
31
|
}
|
|
14
32
|
const base64UserId = Buffer.from(userId, 'utf8').toString('base64url');
|
|
15
33
|
const hmac = crypto.createHmac('sha256', secret);
|
|
@@ -37,18 +55,58 @@ export const auth = router({
|
|
|
37
55
|
message: 'Profile updated successfully',
|
|
38
56
|
};
|
|
39
57
|
}),
|
|
40
|
-
|
|
58
|
+
changePassword: authedProcedure
|
|
59
|
+
.input(z.object({
|
|
60
|
+
currentPassword: z.string().min(1, "Current password is required"),
|
|
61
|
+
newPassword: passwordFieldSchema,
|
|
62
|
+
}))
|
|
41
63
|
.mutation(async ({ ctx, input }) => {
|
|
42
|
-
const
|
|
64
|
+
const user = await ctx.db.user.findUnique({
|
|
65
|
+
where: { id: ctx.session.user.id },
|
|
66
|
+
select: { id: true, passwordHash: true },
|
|
67
|
+
});
|
|
68
|
+
if (!user) {
|
|
69
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
|
|
70
|
+
}
|
|
71
|
+
if (!user.passwordHash) {
|
|
72
|
+
throw new TRPCError({
|
|
73
|
+
code: 'BAD_REQUEST',
|
|
74
|
+
message: 'Password change is unavailable for this account.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const validCurrentPassword = await bcrypt.compare(input.currentPassword, user.passwordHash);
|
|
78
|
+
if (!validCurrentPassword) {
|
|
79
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Current password is incorrect' });
|
|
80
|
+
}
|
|
81
|
+
const isSamePassword = await bcrypt.compare(input.newPassword, user.passwordHash);
|
|
82
|
+
if (isSamePassword) {
|
|
83
|
+
throw new TRPCError({
|
|
84
|
+
code: 'BAD_REQUEST',
|
|
85
|
+
message: 'New password must be different from current password',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const newHash = await bcrypt.hash(input.newPassword, 10);
|
|
89
|
+
await ctx.db.user.update({
|
|
90
|
+
where: { id: user.id },
|
|
91
|
+
data: { passwordHash: newHash },
|
|
92
|
+
});
|
|
93
|
+
return { success: true, message: 'Password changed successfully' };
|
|
94
|
+
}),
|
|
95
|
+
uploadProfilePicture: authedProcedure
|
|
96
|
+
.mutation(async ({ ctx }) => {
|
|
97
|
+
const userId = ctx.session.user.id;
|
|
98
|
+
logger.info(`Generating upload URL for user ${userId}`, 'AUTH');
|
|
99
|
+
const objectKey = `profile_picture_${userId}`;
|
|
43
100
|
const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
|
|
44
101
|
.from('media')
|
|
45
|
-
.createSignedUploadUrl(objectKey, { upsert: true });
|
|
102
|
+
.createSignedUploadUrl(objectKey, { upsert: true });
|
|
46
103
|
if (signedUrlError) {
|
|
104
|
+
logger.error(`Failed to generate upload URL: ${signedUrlError.message}`, 'AUTH');
|
|
47
105
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to generate upload URL: ${signedUrlError.message}` });
|
|
48
106
|
}
|
|
49
|
-
await ctx.db.fileAsset.create({
|
|
107
|
+
const fileAsset = await ctx.db.fileAsset.create({
|
|
50
108
|
data: {
|
|
51
|
-
userId:
|
|
109
|
+
userId: userId,
|
|
52
110
|
name: 'Profile Picture',
|
|
53
111
|
mimeType: 'image/jpeg',
|
|
54
112
|
size: 0,
|
|
@@ -56,36 +114,139 @@ export const auth = router({
|
|
|
56
114
|
objectKey: objectKey,
|
|
57
115
|
},
|
|
58
116
|
});
|
|
117
|
+
await ctx.db.user.update({
|
|
118
|
+
where: { id: userId },
|
|
119
|
+
data: {
|
|
120
|
+
fileAssetId: fileAsset.id,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
logger.info(`Profile picture asset created and linked for user ${userId}`, 'AUTH');
|
|
59
124
|
return {
|
|
60
125
|
success: true,
|
|
61
126
|
message: 'Profile picture uploaded successfully',
|
|
62
127
|
signedUrl: signedUrlData.signedUrl,
|
|
63
128
|
};
|
|
64
129
|
}),
|
|
130
|
+
confirmProfileUpdate: authedProcedure
|
|
131
|
+
.mutation(async ({ ctx }) => {
|
|
132
|
+
logger.info(`Confirming profile update for user ${ctx.session.user.id}`, 'AUTH');
|
|
133
|
+
await PusherService.emitProfileUpdate(ctx.session.user.id);
|
|
134
|
+
return { success: true };
|
|
135
|
+
}),
|
|
65
136
|
signup: publicProcedure
|
|
66
137
|
.input(z.object({
|
|
67
138
|
name: z.string().min(1),
|
|
68
139
|
email: z.string().email(),
|
|
69
|
-
password:
|
|
140
|
+
password: passwordFieldSchema,
|
|
70
141
|
}))
|
|
71
142
|
.mutation(async ({ ctx, input }) => {
|
|
72
143
|
const existing = await ctx.db.user.findUnique({
|
|
73
144
|
where: { email: input.email },
|
|
74
145
|
});
|
|
75
146
|
if (existing) {
|
|
76
|
-
throw new
|
|
147
|
+
throw new TRPCError({ code: 'CONFLICT', message: "Email already registered" });
|
|
77
148
|
}
|
|
78
149
|
const hash = await bcrypt.hash(input.password, 10);
|
|
150
|
+
// Get default "User" role
|
|
151
|
+
const userRole = await ctx.db.role.findUnique({
|
|
152
|
+
where: { name: 'User' },
|
|
153
|
+
});
|
|
79
154
|
const user = await ctx.db.user.create({
|
|
80
155
|
data: {
|
|
81
156
|
name: input.name,
|
|
82
157
|
email: input.email,
|
|
83
158
|
passwordHash: hash,
|
|
84
|
-
|
|
159
|
+
roleId: userRole?.id,
|
|
160
|
+
// emailVerified is null -- user must verify
|
|
85
161
|
},
|
|
86
162
|
});
|
|
163
|
+
await notifyAdminsOnSignup(ctx.db, {
|
|
164
|
+
id: user.id,
|
|
165
|
+
name: user.name,
|
|
166
|
+
email: user.email,
|
|
167
|
+
});
|
|
168
|
+
// Create verification token (24h expiry)
|
|
169
|
+
const token = crypto.randomUUID();
|
|
170
|
+
await ctx.db.verificationToken.create({
|
|
171
|
+
data: {
|
|
172
|
+
identifier: input.email,
|
|
173
|
+
token,
|
|
174
|
+
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
// Send verification email (non-blocking)
|
|
178
|
+
sendVerificationEmail(input.email, token, input.name).catch(() => { });
|
|
179
|
+
// Create Stripe Customer (non-blocking for registration, but we want it)
|
|
180
|
+
createStripeCustomer(input.email, input.name).then(async (stripeCustomerId) => {
|
|
181
|
+
if (stripeCustomerId) {
|
|
182
|
+
await ctx.db.user.update({
|
|
183
|
+
where: { id: user.id },
|
|
184
|
+
data: { stripe_customer_id: stripeCustomerId }
|
|
185
|
+
}).catch((err) => logger.error(`Failed to update user with stripe_customer_id: ${err.message}`, 'AUTH'));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
87
188
|
return { id: user.id, email: user.email, name: user.name };
|
|
88
189
|
}),
|
|
190
|
+
// Verify email with token from the email link
|
|
191
|
+
verifyEmail: publicProcedure
|
|
192
|
+
.input(z.object({
|
|
193
|
+
token: z.string(),
|
|
194
|
+
}))
|
|
195
|
+
.mutation(async ({ ctx, input }) => {
|
|
196
|
+
const record = await ctx.db.verificationToken.findUnique({
|
|
197
|
+
where: { token: input.token },
|
|
198
|
+
});
|
|
199
|
+
if (!record) {
|
|
200
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired verification link' });
|
|
201
|
+
}
|
|
202
|
+
if (record.expires < new Date()) {
|
|
203
|
+
// Clean up expired token
|
|
204
|
+
await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
|
|
205
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Verification link has expired. Please request a new one.' });
|
|
206
|
+
}
|
|
207
|
+
// Mark user as verified
|
|
208
|
+
await ctx.db.user.update({
|
|
209
|
+
where: { email: record.identifier },
|
|
210
|
+
data: { emailVerified: new Date() },
|
|
211
|
+
});
|
|
212
|
+
// Delete used token
|
|
213
|
+
await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
|
|
214
|
+
return { success: true, message: 'Email verified successfully' };
|
|
215
|
+
}),
|
|
216
|
+
// Resend verification email (for logged-in users who haven't verified)
|
|
217
|
+
resendVerification: publicProcedure
|
|
218
|
+
.mutation(async ({ ctx }) => {
|
|
219
|
+
if (!ctx.session?.user?.id) {
|
|
220
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not logged in' });
|
|
221
|
+
}
|
|
222
|
+
const user = await ctx.db.user.findUnique({
|
|
223
|
+
where: { id: ctx.session.user.id },
|
|
224
|
+
});
|
|
225
|
+
if (!user || !user.email) {
|
|
226
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
|
|
227
|
+
}
|
|
228
|
+
if (user.emailVerified) {
|
|
229
|
+
return { success: true, message: 'Email is already verified' };
|
|
230
|
+
}
|
|
231
|
+
// Delete any existing tokens for this email
|
|
232
|
+
await ctx.db.verificationToken.deleteMany({
|
|
233
|
+
where: { identifier: user.email },
|
|
234
|
+
});
|
|
235
|
+
// Create new token
|
|
236
|
+
const token = crypto.randomUUID();
|
|
237
|
+
await ctx.db.verificationToken.create({
|
|
238
|
+
data: {
|
|
239
|
+
identifier: user.email,
|
|
240
|
+
token,
|
|
241
|
+
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
const sent = await sendVerificationEmail(user.email, token, user.name);
|
|
245
|
+
if (!sent) {
|
|
246
|
+
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send email. Please try again.' });
|
|
247
|
+
}
|
|
248
|
+
return { success: true, message: 'Verification email sent' };
|
|
249
|
+
}),
|
|
89
250
|
login: publicProcedure
|
|
90
251
|
.input(z.object({
|
|
91
252
|
email: z.string().email(),
|
|
@@ -96,11 +257,14 @@ export const auth = router({
|
|
|
96
257
|
where: { email: input.email },
|
|
97
258
|
});
|
|
98
259
|
if (!user) {
|
|
99
|
-
throw new
|
|
260
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
|
|
261
|
+
}
|
|
262
|
+
if (user.deletedAt) {
|
|
263
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "Account scheduled for deletion. Please check your email for a restore link." });
|
|
100
264
|
}
|
|
101
265
|
const valid = await bcrypt.compare(input.password, user.passwordHash);
|
|
102
266
|
if (!valid) {
|
|
103
|
-
throw new
|
|
267
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
|
|
104
268
|
}
|
|
105
269
|
// Create custom auth token
|
|
106
270
|
const authToken = createCustomAuthToken(user.id);
|
|
@@ -121,33 +285,189 @@ export const auth = router({
|
|
|
121
285
|
token: authToken
|
|
122
286
|
};
|
|
123
287
|
}),
|
|
288
|
+
/**
|
|
289
|
+
* Request a password reset email. Always returns the same message (no email enumeration).
|
|
290
|
+
*/
|
|
291
|
+
requestPasswordReset: publicProcedure
|
|
292
|
+
.input(z.object({ email: z.string().email() }))
|
|
293
|
+
.mutation(async ({ ctx, input }) => {
|
|
294
|
+
const email = input.email.trim().toLowerCase();
|
|
295
|
+
const generic = {
|
|
296
|
+
success: true,
|
|
297
|
+
message: 'If an account exists for this email, we sent password reset instructions.',
|
|
298
|
+
};
|
|
299
|
+
const user = await ctx.db.user.findUnique({
|
|
300
|
+
where: { email },
|
|
301
|
+
select: {
|
|
302
|
+
id: true,
|
|
303
|
+
email: true,
|
|
304
|
+
name: true,
|
|
305
|
+
passwordHash: true,
|
|
306
|
+
deletedAt: true,
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
if (!user?.passwordHash || user.deletedAt || !user.email) {
|
|
310
|
+
return generic;
|
|
311
|
+
}
|
|
312
|
+
await passwordResetDb(ctx).deleteMany({
|
|
313
|
+
where: { userId: user.id, usedAt: null },
|
|
314
|
+
});
|
|
315
|
+
const rawToken = crypto.randomBytes(32).toString('hex');
|
|
316
|
+
const tokenHash = hashPasswordResetToken(rawToken);
|
|
317
|
+
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
|
318
|
+
await passwordResetDb(ctx).create({
|
|
319
|
+
data: {
|
|
320
|
+
userId: user.id,
|
|
321
|
+
tokenHash,
|
|
322
|
+
expiresAt,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
sendPasswordResetEmail(user.email, rawToken, user.name).catch(() => { });
|
|
326
|
+
return generic;
|
|
327
|
+
}),
|
|
328
|
+
/**
|
|
329
|
+
* Complete password reset using the token from the email link.
|
|
330
|
+
*/
|
|
331
|
+
resetPassword: publicProcedure
|
|
332
|
+
.input(z.object({
|
|
333
|
+
token: z.string().min(1),
|
|
334
|
+
newPassword: passwordFieldSchema,
|
|
335
|
+
}))
|
|
336
|
+
.mutation(async ({ ctx, input }) => {
|
|
337
|
+
const tokenHash = hashPasswordResetToken(input.token);
|
|
338
|
+
const record = await passwordResetDb(ctx).findUnique({
|
|
339
|
+
where: { tokenHash },
|
|
340
|
+
});
|
|
341
|
+
if (!record || record.usedAt || record.expiresAt < new Date()) {
|
|
342
|
+
throw new TRPCError({
|
|
343
|
+
code: 'BAD_REQUEST',
|
|
344
|
+
message: 'Invalid or expired reset link. Please request a new one.',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const newHash = await bcrypt.hash(input.newPassword, 10);
|
|
348
|
+
await ctx.db.user.update({
|
|
349
|
+
where: { id: record.userId },
|
|
350
|
+
data: { passwordHash: newHash },
|
|
351
|
+
});
|
|
352
|
+
await passwordResetDb(ctx).update({
|
|
353
|
+
where: { id: record.id },
|
|
354
|
+
data: { usedAt: new Date() },
|
|
355
|
+
});
|
|
356
|
+
await passwordResetDb(ctx).deleteMany({
|
|
357
|
+
where: { userId: record.userId, id: { not: record.id } },
|
|
358
|
+
});
|
|
359
|
+
return { success: true, message: 'Password updated. You can sign in now.' };
|
|
360
|
+
}),
|
|
124
361
|
getSession: publicProcedure.query(async ({ ctx }) => {
|
|
125
362
|
// Just return the current session from context
|
|
126
363
|
if (!ctx.session) {
|
|
127
|
-
throw new
|
|
364
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "No session found" });
|
|
128
365
|
}
|
|
366
|
+
const userId = ctx.session.user.id;
|
|
129
367
|
const user = await ctx.db.user.findUnique({
|
|
130
|
-
where: { id:
|
|
368
|
+
where: { id: userId },
|
|
369
|
+
include: {
|
|
370
|
+
profilePicture: true,
|
|
371
|
+
role: true,
|
|
372
|
+
}
|
|
131
373
|
});
|
|
132
374
|
if (!user) {
|
|
133
|
-
throw new
|
|
375
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: "User not found" });
|
|
134
376
|
}
|
|
377
|
+
const profilePictureUrl = user.profilePicture?.objectKey
|
|
378
|
+
? `/profile-picture/${user.profilePicture.objectKey}?t=${new Date(user.updatedAt).getTime()}`
|
|
379
|
+
: null;
|
|
380
|
+
logger.info(`Session fetched for user ${userId}, profilePicture: ${profilePictureUrl}`, 'AUTH');
|
|
135
381
|
return {
|
|
136
382
|
user: {
|
|
137
383
|
id: user.id,
|
|
138
384
|
email: user.email,
|
|
139
385
|
name: user.name,
|
|
386
|
+
emailVerified: !!user.emailVerified,
|
|
387
|
+
profilePicture: profilePictureUrl,
|
|
388
|
+
role: user.role,
|
|
140
389
|
}
|
|
141
390
|
};
|
|
142
391
|
}),
|
|
392
|
+
requestAccountDeletion: authedProcedure
|
|
393
|
+
.mutation(async ({ ctx }) => {
|
|
394
|
+
const user = await ctx.db.user.findUnique({
|
|
395
|
+
where: { id: ctx.session.user.id },
|
|
396
|
+
});
|
|
397
|
+
if (!user) {
|
|
398
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
|
|
399
|
+
}
|
|
400
|
+
// Mark user as deleted
|
|
401
|
+
await ctx.db.user.update({
|
|
402
|
+
where: { id: user.id },
|
|
403
|
+
data: { deletedAt: new Date() },
|
|
404
|
+
});
|
|
405
|
+
await notifyAdminsAccountDeletionScheduled(ctx.db, {
|
|
406
|
+
id: user.id,
|
|
407
|
+
name: user.name,
|
|
408
|
+
email: user.email,
|
|
409
|
+
}).catch(() => { });
|
|
410
|
+
// Clear existing restore tokens
|
|
411
|
+
await ctx.db.verificationToken.deleteMany({
|
|
412
|
+
where: { identifier: `restore-${user.email}` },
|
|
413
|
+
});
|
|
414
|
+
// Create restore token (30 days expiry)
|
|
415
|
+
const token = crypto.randomUUID();
|
|
416
|
+
await ctx.db.verificationToken.create({
|
|
417
|
+
data: {
|
|
418
|
+
identifier: `restore-${user.email}`,
|
|
419
|
+
token,
|
|
420
|
+
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
// Send email
|
|
424
|
+
if (user.email) {
|
|
425
|
+
sendAccountDeletionScheduledEmail(user.email, token).catch(() => { });
|
|
426
|
+
}
|
|
427
|
+
// Log out user by clearing cookie
|
|
428
|
+
ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
|
|
429
|
+
httpOnly: true,
|
|
430
|
+
secure: process.env.NODE_ENV === "production",
|
|
431
|
+
sameSite: "lax",
|
|
432
|
+
path: "/",
|
|
433
|
+
maxAge: 0,
|
|
434
|
+
}));
|
|
435
|
+
return { success: true, message: 'Account scheduled for deletion' };
|
|
436
|
+
}),
|
|
437
|
+
restoreAccount: publicProcedure
|
|
438
|
+
.input(z.object({
|
|
439
|
+
token: z.string(),
|
|
440
|
+
}))
|
|
441
|
+
.mutation(async ({ ctx, input }) => {
|
|
442
|
+
const record = await ctx.db.verificationToken.findUnique({
|
|
443
|
+
where: { token: input.token },
|
|
444
|
+
});
|
|
445
|
+
if (!record || !record.identifier.startsWith('restore-')) {
|
|
446
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired restore link' });
|
|
447
|
+
}
|
|
448
|
+
if (record.expires < new Date()) {
|
|
449
|
+
await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
|
|
450
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Restore link has expired.' });
|
|
451
|
+
}
|
|
452
|
+
const email = record.identifier.replace('restore-', '');
|
|
453
|
+
// Mark user as restored
|
|
454
|
+
await ctx.db.user.update({
|
|
455
|
+
where: { email },
|
|
456
|
+
data: { deletedAt: null },
|
|
457
|
+
});
|
|
458
|
+
// Delete used token
|
|
459
|
+
await ctx.db.verificationToken.deleteMany({ where: { token: input.token } });
|
|
460
|
+
// Send confirmation email
|
|
461
|
+
sendAccountRestoredEmail(email).catch(() => { });
|
|
462
|
+
return { success: true, message: 'Account restored successfully' };
|
|
463
|
+
}),
|
|
143
464
|
logout: publicProcedure.mutation(async ({ ctx }) => {
|
|
144
465
|
const token = ctx.cookies["auth_token"];
|
|
145
466
|
if (!token) {
|
|
146
|
-
throw new
|
|
467
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: "No token found" });
|
|
147
468
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
});
|
|
469
|
+
// We don't need to delete from db.session because we use a stateless
|
|
470
|
+
// custom HMAC auth system (auth_token cookie).
|
|
151
471
|
ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
|
|
152
472
|
httpOnly: true,
|
|
153
473
|
secure: process.env.NODE_ENV === "production",
|
package/dist/routers/chat.d.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
export declare const chat: import("@trpc/server").TRPCBuiltRouter<{
|
|
2
|
-
ctx:
|
|
3
|
-
db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
4
|
-
session: any;
|
|
5
|
-
req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
|
|
6
|
-
res: import("express").Response<any, Record<string, any>>;
|
|
7
|
-
cookies: Record<string, string | undefined>;
|
|
8
|
-
};
|
|
2
|
+
ctx: import("../context.js").Context;
|
|
9
3
|
meta: object;
|
|
10
4
|
errorShape: {
|
|
11
5
|
data: {
|
|
@@ -47,8 +41,8 @@ export declare const chat: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
47
41
|
id: string;
|
|
48
42
|
createdAt: Date;
|
|
49
43
|
updatedAt: Date;
|
|
50
|
-
userId: string | null;
|
|
51
44
|
channelId: string;
|
|
45
|
+
userId: string | null;
|
|
52
46
|
message: string;
|
|
53
47
|
})[];
|
|
54
48
|
} & {
|
|
@@ -85,8 +79,8 @@ export declare const chat: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
85
79
|
id: string;
|
|
86
80
|
createdAt: Date;
|
|
87
81
|
updatedAt: Date;
|
|
88
|
-
userId: string | null;
|
|
89
82
|
channelId: string;
|
|
83
|
+
userId: string | null;
|
|
90
84
|
message: string;
|
|
91
85
|
})[];
|
|
92
86
|
} & {
|
|
@@ -112,8 +106,8 @@ export declare const chat: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
112
106
|
id: string;
|
|
113
107
|
createdAt: Date;
|
|
114
108
|
updatedAt: Date;
|
|
115
|
-
userId: string | null;
|
|
116
109
|
channelId: string;
|
|
110
|
+
userId: string | null;
|
|
117
111
|
message: string;
|
|
118
112
|
})[];
|
|
119
113
|
} & {
|
|
@@ -138,8 +132,8 @@ export declare const chat: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
138
132
|
id: string;
|
|
139
133
|
createdAt: Date;
|
|
140
134
|
updatedAt: Date;
|
|
141
|
-
userId: string | null;
|
|
142
135
|
channelId: string;
|
|
136
|
+
userId: string | null;
|
|
143
137
|
message: string;
|
|
144
138
|
};
|
|
145
139
|
meta: object;
|
|
@@ -158,8 +152,8 @@ export declare const chat: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
158
152
|
id: string;
|
|
159
153
|
createdAt: Date;
|
|
160
154
|
updatedAt: Date;
|
|
161
|
-
userId: string | null;
|
|
162
155
|
channelId: string;
|
|
156
|
+
userId: string | null;
|
|
163
157
|
message: string;
|
|
164
158
|
};
|
|
165
159
|
meta: object;
|