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