馃 distributed transcription service
thistle.dunkirk.sh
1import db from "../db/schema";
2
3const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
4
5export type UserRole = "user" | "admin";
6
7export interface User {
8 id: number;
9 email: string;
10 name: string | null;
11 avatar: string;
12 created_at: number;
13 role: UserRole;
14 last_login: number | null;
15}
16
17export interface Session {
18 id: string;
19 user_id: number;
20 ip_address: string | null;
21 user_agent: string | null;
22 created_at: number;
23 expires_at: number;
24}
25
26export function createSession(
27 userId: number,
28 ipAddress?: string,
29 userAgent?: string,
30): string {
31 const sessionId = crypto.randomUUID();
32 const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
33
34 db.run(
35 "INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",
36 [sessionId, userId, ipAddress ?? null, userAgent ?? null, expiresAt],
37 );
38
39 return sessionId;
40}
41
42export function getSession(sessionId: string): Session | null {
43 const now = Math.floor(Date.now() / 1000);
44
45 const session = db
46 .query<Session, [string, number]>(
47 "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE id = ? AND expires_at > ?",
48 )
49 .get(sessionId, now);
50
51 return session ?? null;
52}
53
54export function getUserBySession(sessionId: string): User | null {
55 const session = getSession(sessionId);
56 if (!session) return null;
57
58 const user = db
59 .query<User, [number]>(
60 "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
61 )
62 .get(session.user_id);
63
64 return user ?? null;
65}
66
67export function getUserByEmail(email: string): User | null {
68 const user = db
69 .query<User, [string]>(
70 "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE email = ?",
71 )
72 .get(email);
73
74 return user ?? null;
75}
76
77export function deleteSession(sessionId: string): void {
78 db.run("DELETE FROM sessions WHERE id = ?", [sessionId]);
79}
80
81export function cleanupExpiredSessions(): void {
82 const now = Math.floor(Date.now() / 1000);
83 db.run("DELETE FROM sessions WHERE expires_at <= ?", [now]);
84}
85
86export async function createUser(
87 email: string,
88 password: string,
89 name?: string,
90): Promise<User> {
91 // Generate deterministic avatar from email
92 const encoder = new TextEncoder();
93 const data = encoder.encode(email.toLowerCase());
94 const hashBuffer = await crypto.subtle.digest("SHA-256", data);
95 const hashArray = Array.from(new Uint8Array(hashBuffer));
96 const avatar = hashArray
97 .map((b) => b.toString(16).padStart(2, "0"))
98 .join("")
99 .substring(0, 16);
100
101 const result = db.run(
102 "INSERT INTO users (email, password_hash, name, avatar) VALUES (?, ?, ?, ?)",
103 [email, password, name ?? null, avatar],
104 );
105
106 const user = db
107 .query<User, [number]>(
108 "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE id = ?",
109 )
110 .get(Number(result.lastInsertRowid));
111
112 if (!user) {
113 throw new Error("Failed to create user");
114 }
115
116 return user;
117}
118
119export async function authenticateUser(
120 email: string,
121 password: string,
122): Promise<User | null> {
123 const result = db
124 .query<
125 {
126 id: number;
127 email: string;
128 name: string | null;
129 avatar: string;
130 password_hash: string;
131 created_at: number;
132 role: UserRole;
133 last_login: number | null;
134 },
135 [string]
136 >(
137 "SELECT id, email, name, avatar, password_hash, created_at, role, last_login FROM users WHERE email = ?",
138 )
139 .get(email);
140
141 if (!result) {
142 // Dummy comparison to prevent timing-based account enumeration
143 const dummyHash = "0".repeat(64);
144 password === dummyHash;
145 return null;
146 }
147
148 if (password !== result.password_hash) return null;
149
150 // Update last_login
151 const now = Math.floor(Date.now() / 1000);
152 db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, result.id]);
153
154 return {
155 id: result.id,
156 email: result.email,
157 name: result.name,
158 avatar: result.avatar,
159 created_at: result.created_at,
160 role: result.role,
161 last_login: now,
162 };
163}
164
165export function getUserSessionsForUser(userId: number): Session[] {
166 const now = Math.floor(Date.now() / 1000);
167
168 const sessions = db
169 .query<Session, [number, number]>(
170 "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC",
171 )
172 .all(userId, now);
173
174 return sessions;
175}
176
177export function getSessionFromRequest(req: Request): string | null {
178 const cookie = req.headers.get("cookie");
179 if (!cookie) return null;
180
181 const match = cookie.match(/session=([^;]+)/);
182 return match?.[1] ?? null;
183}
184
185export async function deleteUser(userId: number): Promise<void> {
186 // Prevent deleting the ghost user
187 if (userId === 0) {
188 throw new Error("Cannot delete ghost user account");
189 }
190
191 // Get user's subscription if they have one
192 const subscription = db
193 .query<
194 { id: string; status: string; cancel_at_period_end: number },
195 [number]
196 >(
197 "SELECT id, status, cancel_at_period_end FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
198 )
199 .get(userId);
200
201 // Cancel subscription if it exists and is not already canceled or scheduled to cancel
202 if (
203 subscription &&
204 subscription.status !== "canceled" &&
205 subscription.status !== "expired" &&
206 !subscription.cancel_at_period_end
207 ) {
208 try {
209 const { polar } = await import("./polar");
210 await polar.subscriptions.update({
211 id: subscription.id,
212 subscriptionUpdate: { cancelAtPeriodEnd: true },
213 });
214 console.log(
215 `[User Delete] Canceled subscription ${subscription.id} for user ${userId}`,
216 );
217 } catch (error) {
218 console.error(
219 `[User Delete] Failed to cancel subscription ${subscription.id}:`,
220 error,
221 );
222 // Continue with user deletion even if subscription cancellation fails
223 }
224 } else if (subscription) {
225 console.log(
226 `[User Delete] Skipping cancellation for subscription ${subscription.id} (status: ${subscription.status}, cancel_at_period_end: ${subscription.cancel_at_period_end})`,
227 );
228 }
229
230 // Reassign class transcriptions to ghost user (id=0)
231 // Delete personal transcriptions (no class_id)
232 db.run(
233 "UPDATE transcriptions SET user_id = 0 WHERE user_id = ? AND class_id IS NOT NULL",
234 [userId],
235 );
236 db.run("DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL", [
237 userId,
238 ]);
239
240 // Delete user (CASCADE will handle sessions, passkeys, subscriptions, class_members)
241 db.run("DELETE FROM users WHERE id = ?", [userId]);
242}
243
244export function updateUserEmail(userId: number, newEmail: string): void {
245 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
246}
247
248export function updateUserName(userId: number, newName: string): void {
249 db.run("UPDATE users SET name = ? WHERE id = ?", [newName, userId]);
250}
251
252export function updateUserAvatar(userId: number, avatar: string): void {
253 db.run("UPDATE users SET avatar = ? WHERE id = ?", [avatar, userId]);
254}
255
256export async function updateUserPassword(
257 userId: number,
258 newPassword: string,
259): Promise<void> {
260 db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
261 newPassword,
262 userId,
263 ]);
264 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
265}
266
267/**
268 * Email verification functions
269 */
270
271export function createEmailVerificationToken(userId: number): {
272 code: string;
273 token: string;
274 sentAt: number;
275} {
276 // Generate a 6-digit code for user to enter
277 const code = Math.floor(100000 + Math.random() * 900000).toString();
278 const id = crypto.randomUUID();
279 const token = crypto.randomUUID(); // Separate token for URL
280 const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
281 const sentAt = Math.floor(Date.now() / 1000); // Timestamp when code is created
282
283 // Delete any existing tokens for this user
284 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
285
286 // Store the code as the token field (for manual entry)
287 db.run(
288 "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
289 [id, userId, code, expiresAt],
290 );
291
292 // Store the URL token as a separate entry
293 db.run(
294 "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
295 [crypto.randomUUID(), userId, token, expiresAt],
296 );
297
298 return { code, token, sentAt };
299}
300
301export function verifyEmailToken(
302 token: string,
303): { userId: number; email: string } | null {
304 const now = Math.floor(Date.now() / 1000);
305
306 const result = db
307 .query<{ user_id: number; email: string }, [string, number]>(
308 `SELECT evt.user_id, u.email
309 FROM email_verification_tokens evt
310 JOIN users u ON evt.user_id = u.id
311 WHERE evt.token = ? AND evt.expires_at > ?`,
312 )
313 .get(token, now);
314
315 if (!result) return null;
316
317 // Mark email as verified
318 db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [result.user_id]);
319
320 // Delete the token (one-time use)
321 db.run("DELETE FROM email_verification_tokens WHERE token = ?", [token]);
322
323 return { userId: result.user_id, email: result.email };
324}
325
326export function verifyEmailCode(userId: number, code: string): boolean {
327 const now = Math.floor(Date.now() / 1000);
328
329 const result = db
330 .query<{ user_id: number }, [number, string, number]>(
331 `SELECT user_id
332 FROM email_verification_tokens
333 WHERE user_id = ? AND token = ? AND expires_at > ?`,
334 )
335 .get(userId, code, now);
336
337 if (!result) return false;
338
339 // Mark email as verified
340 db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [userId]);
341
342 // Delete the token (one-time use)
343 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
344
345 return true;
346}
347
348export function isEmailVerified(userId: number): boolean {
349 const result = db
350 .query<{ email_verified: number }, [number]>(
351 "SELECT email_verified FROM users WHERE id = ?",
352 )
353 .get(userId);
354
355 return result?.email_verified === 1;
356}
357
358export function getVerificationCodeSentAt(userId: number): number | null {
359 const result = db
360 .query<{ created_at: number }, [number]>(
361 "SELECT MAX(created_at) as created_at FROM email_verification_tokens WHERE user_id = ?",
362 )
363 .get(userId);
364
365 return result?.created_at ?? null;
366}
367
368/**
369 * Password reset functions
370 */
371
372export function createPasswordResetToken(userId: number): string {
373 const token = crypto.randomUUID();
374 const id = crypto.randomUUID();
375 const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
376
377 // Delete any existing tokens for this user
378 db.run("DELETE FROM password_reset_tokens WHERE user_id = ?", [userId]);
379
380 db.run(
381 "INSERT INTO password_reset_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
382 [id, userId, token, expiresAt],
383 );
384
385 return token;
386}
387
388export function verifyPasswordResetToken(token: string): number | null {
389 const now = Math.floor(Date.now() / 1000);
390
391 const result = db
392 .query<{ user_id: number }, [string, number]>(
393 "SELECT user_id FROM password_reset_tokens WHERE token = ? AND expires_at > ?",
394 )
395 .get(token, now);
396
397 return result?.user_id ?? null;
398}
399
400export function consumePasswordResetToken(token: string): void {
401 db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]);
402}
403
404/**
405 * Email change functions
406 */
407
408export function createEmailChangeToken(
409 userId: number,
410 newEmail: string,
411): string {
412 const token = crypto.randomUUID();
413 const id = crypto.randomUUID();
414 const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
415
416 // Delete any existing email change tokens for this user
417 db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [userId]);
418
419 db.run(
420 "INSERT INTO email_change_tokens (id, user_id, new_email, token, expires_at) VALUES (?, ?, ?, ?, ?)",
421 [id, userId, newEmail, token, expiresAt],
422 );
423
424 return token;
425}
426
427export function verifyEmailChangeToken(
428 token: string,
429): { userId: number; newEmail: string } | null {
430 const now = Math.floor(Date.now() / 1000);
431
432 const result = db
433 .query<{ user_id: number; new_email: string }, [string, number]>(
434 "SELECT user_id, new_email FROM email_change_tokens WHERE token = ? AND expires_at > ?",
435 )
436 .get(token, now);
437
438 if (!result) return null;
439
440 return { userId: result.user_id, newEmail: result.new_email };
441}
442
443export function consumeEmailChangeToken(token: string): void {
444 db.run("DELETE FROM email_change_tokens WHERE token = ?", [token]);
445}
446
447export function isUserAdmin(userId: number): boolean {
448 const result = db
449 .query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
450 .get(userId);
451
452 return result?.role === "admin";
453}
454
455export function updateUserRole(userId: number, role: UserRole): void {
456 db.run("UPDATE users SET role = ? WHERE id = ?", [role, userId]);
457}
458
459export function getAllUsers(): Array<{
460 id: number;
461 email: string;
462 name: string | null;
463 avatar: string;
464 created_at: number;
465 role: UserRole;
466}> {
467 return db
468 .query<
469 {
470 id: number;
471 email: string;
472 name: string | null;
473 avatar: string;
474 created_at: number;
475 role: UserRole;
476 last_login: number | null;
477 },
478 []
479 >(
480 "SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC",
481 )
482 .all();
483}
484
485export function getAllTranscriptions(): Array<{
486 id: string;
487 user_id: number;
488 user_email: string;
489 user_name: string | null;
490 original_filename: string;
491 status: string;
492 created_at: number;
493 error_message: string | null;
494}> {
495 return db
496 .query<
497 {
498 id: string;
499 user_id: number;
500 user_email: string;
501 user_name: string | null;
502 original_filename: string;
503 status: string;
504 created_at: number;
505 error_message: string | null;
506 },
507 []
508 >(
509 `SELECT
510 t.id,
511 t.user_id,
512 u.email as user_email,
513 u.name as user_name,
514 t.original_filename,
515 t.status,
516 t.created_at,
517 t.error_message
518 FROM transcriptions t
519 LEFT JOIN users u ON t.user_id = u.id
520 ORDER BY t.created_at DESC`,
521 )
522 .all();
523}
524
525export function deleteTranscription(transcriptionId: string): void {
526 const transcription = db
527 .query<{ id: string; filename: string }, [string]>(
528 "SELECT id, filename FROM transcriptions WHERE id = ?",
529 )
530 .get(transcriptionId);
531
532 if (!transcription) {
533 throw new Error("Transcription not found");
534 }
535
536 // Delete database record
537 db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]);
538
539 // Delete files (audio file and transcript files)
540 try {
541 const audioPath = `./uploads/${transcription.filename}`;
542 const transcriptPath = `./transcripts/${transcriptionId}.txt`;
543 const vttPath = `./transcripts/${transcriptionId}.vtt`;
544
545 if (Bun.file(audioPath).size) {
546 Bun.write(audioPath, "").then(() => {
547 // File deleted by overwriting with empty content, then unlink
548 import("node:fs").then((fs) => {
549 fs.unlinkSync(audioPath);
550 });
551 });
552 }
553
554 if (Bun.file(transcriptPath).size) {
555 import("node:fs").then((fs) => {
556 fs.unlinkSync(transcriptPath);
557 });
558 }
559
560 if (Bun.file(vttPath).size) {
561 import("node:fs").then((fs) => {
562 fs.unlinkSync(vttPath);
563 });
564 }
565 } catch {
566 // Files might not exist, ignore errors
567 }
568}
569
570export function getSessionsForUser(userId: number): Session[] {
571 const now = Math.floor(Date.now() / 1000);
572 return db
573 .query<Session, [number, number]>(
574 "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC",
575 )
576 .all(userId, now);
577}
578
579export function deleteSessionById(sessionId: string, userId: number): boolean {
580 const result = db.run("DELETE FROM sessions WHERE id = ? AND user_id = ?", [
581 sessionId,
582 userId,
583 ]);
584 return result.changes > 0;
585}
586
587export function deleteAllUserSessions(userId: number): void {
588 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
589}
590
591export function updateUserEmailAddress(userId: number, newEmail: string): void {
592 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
593}
594
595export interface UserWithStats {
596 id: number;
597 email: string;
598 name: string | null;
599 avatar: string;
600 created_at: number;
601 role: UserRole;
602 last_login: number | null;
603 transcription_count: number;
604 subscription_status: string | null;
605 subscription_id: string | null;
606}
607
608export function getAllUsersWithStats(): UserWithStats[] {
609 return db
610 .query<UserWithStats, []>(
611 `SELECT
612 u.id,
613 u.email,
614 u.name,
615 u.avatar,
616 u.created_at,
617 u.role,
618 u.last_login,
619 COUNT(DISTINCT t.id) as transcription_count,
620 s.status as subscription_status,
621 s.id as subscription_id
622 FROM users u
623 LEFT JOIN transcriptions t ON u.id = t.user_id
624 LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
625 GROUP BY u.id
626 ORDER BY u.created_at DESC`,
627 )
628 .all();
629}