馃 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<{ id: string }, [number]>(
194 "SELECT id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
195 )
196 .get(userId);
197
198 // Cancel subscription if it exists (soft cancel - keeps access until period end)
199 if (subscription) {
200 try {
201 const { polar } = await import("./polar");
202 await polar.subscriptions.update({
203 id: subscription.id,
204 subscriptionUpdate: { cancelAtPeriodEnd: true },
205 });
206 console.log(
207 `[User Delete] Canceled subscription ${subscription.id} for user ${userId}`,
208 );
209 } catch (error) {
210 console.error(
211 `[User Delete] Failed to cancel subscription ${subscription.id}:`,
212 error,
213 );
214 // Continue with user deletion even if subscription cancellation fails
215 }
216 }
217
218 // Reassign class transcriptions to ghost user (id=0)
219 // Delete personal transcriptions (no class_id)
220 db.run(
221 "UPDATE transcriptions SET user_id = 0 WHERE user_id = ? AND class_id IS NOT NULL",
222 [userId],
223 );
224 db.run(
225 "DELETE FROM transcriptions WHERE user_id = ? AND class_id IS NULL",
226 [userId],
227 );
228
229 // Delete user (CASCADE will handle sessions, passkeys, subscriptions, class_members)
230 db.run("DELETE FROM users WHERE id = ?", [userId]);
231}
232
233export function updateUserEmail(userId: number, newEmail: string): void {
234 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
235}
236
237export function updateUserName(userId: number, newName: string): void {
238 db.run("UPDATE users SET name = ? WHERE id = ?", [newName, userId]);
239}
240
241export function updateUserAvatar(userId: number, avatar: string): void {
242 db.run("UPDATE users SET avatar = ? WHERE id = ?", [avatar, userId]);
243}
244
245export async function updateUserPassword(
246 userId: number,
247 newPassword: string,
248): Promise<void> {
249 db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
250 newPassword,
251 userId,
252 ]);
253 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
254}
255
256/**
257 * Email verification functions
258 */
259
260export function createEmailVerificationToken(userId: number): { code: string; token: string } {
261 // Generate a 6-digit code for user to enter
262 const code = Math.floor(100000 + Math.random() * 900000).toString();
263 const id = crypto.randomUUID();
264 const token = crypto.randomUUID(); // Separate token for URL
265 const expiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; // 24 hours
266
267 // Delete any existing tokens for this user
268 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
269
270 // Store the code as the token field (for manual entry)
271 db.run(
272 "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
273 [id, userId, code, expiresAt],
274 );
275
276 // Store the URL token as a separate entry
277 db.run(
278 "INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
279 [crypto.randomUUID(), userId, token, expiresAt],
280 );
281
282 return { code, token };
283}
284
285export function verifyEmailToken(
286 token: string,
287): { userId: number; email: string } | null {
288 const now = Math.floor(Date.now() / 1000);
289
290 const result = db
291 .query<
292 { user_id: number; email: string },
293 [string, number]
294 >(
295 `SELECT evt.user_id, u.email
296 FROM email_verification_tokens evt
297 JOIN users u ON evt.user_id = u.id
298 WHERE evt.token = ? AND evt.expires_at > ?`,
299 )
300 .get(token, now);
301
302 if (!result) return null;
303
304 // Mark email as verified
305 db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [result.user_id]);
306
307 // Delete the token (one-time use)
308 db.run("DELETE FROM email_verification_tokens WHERE token = ?", [token]);
309
310 return { userId: result.user_id, email: result.email };
311}
312
313export function verifyEmailCode(
314 userId: number,
315 code: string,
316): boolean {
317 const now = Math.floor(Date.now() / 1000);
318
319 const result = db
320 .query<
321 { user_id: number },
322 [number, string, number]
323 >(
324 `SELECT user_id
325 FROM email_verification_tokens
326 WHERE user_id = ? AND token = ? AND expires_at > ?`,
327 )
328 .get(userId, code, now);
329
330 if (!result) return false;
331
332 // Mark email as verified
333 db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [userId]);
334
335 // Delete the token (one-time use)
336 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [userId]);
337
338 return true;
339}
340
341export function isEmailVerified(userId: number): boolean {
342 const result = db
343 .query<{ email_verified: number }, [number]>(
344 "SELECT email_verified FROM users WHERE id = ?",
345 )
346 .get(userId);
347
348 return result?.email_verified === 1;
349}
350
351/**
352 * Password reset functions
353 */
354
355export function createPasswordResetToken(userId: number): string {
356 const token = crypto.randomUUID();
357 const id = crypto.randomUUID();
358 const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60; // 1 hour
359
360 // Delete any existing tokens for this user
361 db.run("DELETE FROM password_reset_tokens WHERE user_id = ?", [userId]);
362
363 db.run(
364 "INSERT INTO password_reset_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",
365 [id, userId, token, expiresAt],
366 );
367
368 return token;
369}
370
371export function verifyPasswordResetToken(token: string): number | null {
372 const now = Math.floor(Date.now() / 1000);
373
374 const result = db
375 .query<{ user_id: number }, [string, number]>(
376 "SELECT user_id FROM password_reset_tokens WHERE token = ? AND expires_at > ?",
377 )
378 .get(token, now);
379
380 return result?.user_id ?? null;
381}
382
383export function consumePasswordResetToken(token: string): void {
384 db.run("DELETE FROM password_reset_tokens WHERE token = ?", [token]);
385}
386
387export function isUserAdmin(userId: number): boolean {
388 const result = db
389 .query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?")
390 .get(userId);
391
392 return result?.role === "admin";
393}
394
395export function updateUserRole(userId: number, role: UserRole): void {
396 db.run("UPDATE users SET role = ? WHERE id = ?", [role, userId]);
397}
398
399export function getAllUsers(): Array<{
400 id: number;
401 email: string;
402 name: string | null;
403 avatar: string;
404 created_at: number;
405 role: UserRole;
406}> {
407 return db
408 .query<
409 {
410 id: number;
411 email: string;
412 name: string | null;
413 avatar: string;
414 created_at: number;
415 role: UserRole;
416 last_login: number | null;
417 },
418 []
419 >(
420 "SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC",
421 )
422 .all();
423}
424
425export function getAllTranscriptions(): Array<{
426 id: string;
427 user_id: number;
428 user_email: string;
429 user_name: string | null;
430 original_filename: string;
431 status: string;
432 created_at: number;
433 error_message: string | null;
434}> {
435 return db
436 .query<
437 {
438 id: string;
439 user_id: number;
440 user_email: string;
441 user_name: string | null;
442 original_filename: string;
443 status: string;
444 created_at: number;
445 error_message: string | null;
446 },
447 []
448 >(
449 `SELECT
450 t.id,
451 t.user_id,
452 u.email as user_email,
453 u.name as user_name,
454 t.original_filename,
455 t.status,
456 t.created_at,
457 t.error_message
458 FROM transcriptions t
459 LEFT JOIN users u ON t.user_id = u.id
460 ORDER BY t.created_at DESC`,
461 )
462 .all();
463}
464
465export function deleteTranscription(transcriptionId: string): void {
466 const transcription = db
467 .query<{ id: string; filename: string }, [string]>(
468 "SELECT id, filename FROM transcriptions WHERE id = ?",
469 )
470 .get(transcriptionId);
471
472 if (!transcription) {
473 throw new Error("Transcription not found");
474 }
475
476 // Delete database record
477 db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]);
478
479 // Delete files (audio file and transcript files)
480 try {
481 const audioPath = `./uploads/${transcription.filename}`;
482 const transcriptPath = `./transcripts/${transcriptionId}.txt`;
483 const vttPath = `./transcripts/${transcriptionId}.vtt`;
484
485 if (Bun.file(audioPath).size) {
486 Bun.write(audioPath, "").then(() => {
487 // File deleted by overwriting with empty content, then unlink
488 import("node:fs").then((fs) => {
489 fs.unlinkSync(audioPath);
490 });
491 });
492 }
493
494 if (Bun.file(transcriptPath).size) {
495 import("node:fs").then((fs) => {
496 fs.unlinkSync(transcriptPath);
497 });
498 }
499
500 if (Bun.file(vttPath).size) {
501 import("node:fs").then((fs) => {
502 fs.unlinkSync(vttPath);
503 });
504 }
505 } catch {
506 // Files might not exist, ignore errors
507 }
508}
509
510export function getSessionsForUser(userId: number): Session[] {
511 const now = Math.floor(Date.now() / 1000);
512 return db
513 .query<Session, [number, number]>(
514 "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",
515 )
516 .all(userId, now);
517}
518
519export function deleteSessionById(sessionId: string, userId: number): boolean {
520 const result = db.run("DELETE FROM sessions WHERE id = ? AND user_id = ?", [
521 sessionId,
522 userId,
523 ]);
524 return result.changes > 0;
525}
526
527export function deleteAllUserSessions(userId: number): void {
528 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
529}
530
531export function updateUserEmailAddress(userId: number, newEmail: string): void {
532 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
533}
534
535export interface UserWithStats {
536 id: number;
537 email: string;
538 name: string | null;
539 avatar: string;
540 created_at: number;
541 role: UserRole;
542 last_login: number | null;
543 transcription_count: number;
544 subscription_status: string | null;
545 subscription_id: string | null;
546}
547
548export function getAllUsersWithStats(): UserWithStats[] {
549 return db
550 .query<UserWithStats, []>(
551 `SELECT
552 u.id,
553 u.email,
554 u.name,
555 u.avatar,
556 u.created_at,
557 u.role,
558 u.last_login,
559 COUNT(DISTINCT t.id) as transcription_count,
560 s.status as subscription_status,
561 s.id as subscription_id
562 FROM users u
563 LEFT JOIN transcriptions t ON u.id = t.user_id
564 LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
565 GROUP BY u.id
566 ORDER BY u.created_at DESC`,
567 )
568 .all();
569}