馃 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 // Get user's subscription if they have one 187 const subscription = db 188 .query<{ id: string }, [number]>( 189 "SELECT id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 190 ) 191 .get(userId); 192 193 // Cancel subscription if it exists (soft cancel - keeps access until period end) 194 if (subscription) { 195 try { 196 const { polar } = await import("./polar"); 197 await polar.subscriptions.update({ 198 id: subscription.id, 199 subscriptionUpdate: { cancelAtPeriodEnd: true }, 200 }); 201 console.log( 202 `[User Delete] Canceled subscription ${subscription.id} for user ${userId}`, 203 ); 204 } catch (error) { 205 console.error( 206 `[User Delete] Failed to cancel subscription ${subscription.id}:`, 207 error, 208 ); 209 // Continue with user deletion even if subscription cancellation fails 210 } 211 } 212 213 db.run("DELETE FROM users WHERE id = ?", [userId]); 214} 215 216export function updateUserEmail(userId: number, newEmail: string): void { 217 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]); 218} 219 220export function updateUserName(userId: number, newName: string): void { 221 db.run("UPDATE users SET name = ? WHERE id = ?", [newName, userId]); 222} 223 224export function updateUserAvatar(userId: number, avatar: string): void { 225 db.run("UPDATE users SET avatar = ? WHERE id = ?", [avatar, userId]); 226} 227 228export async function updateUserPassword( 229 userId: number, 230 newPassword: string, 231): Promise<void> { 232 db.run("UPDATE users SET password_hash = ? WHERE id = ?", [ 233 newPassword, 234 userId, 235 ]); 236 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]); 237} 238 239export function isUserAdmin(userId: number): boolean { 240 const result = db 241 .query<{ role: UserRole }, [number]>("SELECT role FROM users WHERE id = ?") 242 .get(userId); 243 244 return result?.role === "admin"; 245} 246 247export function updateUserRole(userId: number, role: UserRole): void { 248 db.run("UPDATE users SET role = ? WHERE id = ?", [role, userId]); 249} 250 251export function getAllUsers(): Array<{ 252 id: number; 253 email: string; 254 name: string | null; 255 avatar: string; 256 created_at: number; 257 role: UserRole; 258}> { 259 return db 260 .query< 261 { 262 id: number; 263 email: string; 264 name: string | null; 265 avatar: string; 266 created_at: number; 267 role: UserRole; 268 last_login: number | null; 269 }, 270 [] 271 >( 272 "SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC", 273 ) 274 .all(); 275} 276 277export function getAllTranscriptions(): Array<{ 278 id: string; 279 user_id: number; 280 user_email: string; 281 user_name: string | null; 282 original_filename: string; 283 status: string; 284 created_at: number; 285 error_message: string | null; 286}> { 287 return db 288 .query< 289 { 290 id: string; 291 user_id: number; 292 user_email: string; 293 user_name: string | null; 294 original_filename: string; 295 status: string; 296 created_at: number; 297 error_message: string | null; 298 }, 299 [] 300 >( 301 `SELECT 302 t.id, 303 t.user_id, 304 u.email as user_email, 305 u.name as user_name, 306 t.original_filename, 307 t.status, 308 t.created_at, 309 t.error_message 310 FROM transcriptions t 311 LEFT JOIN users u ON t.user_id = u.id 312 ORDER BY t.created_at DESC`, 313 ) 314 .all(); 315} 316 317export function deleteTranscription(transcriptionId: string): void { 318 const transcription = db 319 .query<{ id: string; filename: string }, [string]>( 320 "SELECT id, filename FROM transcriptions WHERE id = ?", 321 ) 322 .get(transcriptionId); 323 324 if (!transcription) { 325 throw new Error("Transcription not found"); 326 } 327 328 // Delete database record 329 db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]); 330 331 // Delete files (audio file and transcript files) 332 try { 333 const audioPath = `./uploads/${transcription.filename}`; 334 const transcriptPath = `./transcripts/${transcriptionId}.txt`; 335 const vttPath = `./transcripts/${transcriptionId}.vtt`; 336 337 if (Bun.file(audioPath).size) { 338 Bun.write(audioPath, "").then(() => { 339 // File deleted by overwriting with empty content, then unlink 340 import("node:fs").then((fs) => { 341 fs.unlinkSync(audioPath); 342 }); 343 }); 344 } 345 346 if (Bun.file(transcriptPath).size) { 347 import("node:fs").then((fs) => { 348 fs.unlinkSync(transcriptPath); 349 }); 350 } 351 352 if (Bun.file(vttPath).size) { 353 import("node:fs").then((fs) => { 354 fs.unlinkSync(vttPath); 355 }); 356 } 357 } catch { 358 // Files might not exist, ignore errors 359 } 360} 361 362export function getSessionsForUser(userId: number): Session[] { 363 const now = Math.floor(Date.now() / 1000); 364 return db 365 .query<Session, [number, number]>( 366 "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", 367 ) 368 .all(userId, now); 369} 370 371export function deleteSessionById(sessionId: string, userId: number): boolean { 372 const result = db.run("DELETE FROM sessions WHERE id = ? AND user_id = ?", [ 373 sessionId, 374 userId, 375 ]); 376 return result.changes > 0; 377} 378 379export function deleteAllUserSessions(userId: number): void { 380 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]); 381} 382 383export function updateUserEmailAddress(userId: number, newEmail: string): void { 384 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]); 385} 386 387export interface UserWithStats { 388 id: number; 389 email: string; 390 name: string | null; 391 avatar: string; 392 created_at: number; 393 role: UserRole; 394 last_login: number | null; 395 transcription_count: number; 396 subscription_status: string | null; 397 subscription_id: string | null; 398} 399 400export function getAllUsersWithStats(): UserWithStats[] { 401 return db 402 .query<UserWithStats, []>( 403 `SELECT 404 u.id, 405 u.email, 406 u.name, 407 u.avatar, 408 u.created_at, 409 u.role, 410 u.last_login, 411 COUNT(DISTINCT t.id) as transcription_count, 412 s.status as subscription_status, 413 s.id as subscription_id 414 FROM users u 415 LEFT JOIN transcriptions t ON u.id = t.user_id 416 LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due') 417 GROUP BY u.id 418 ORDER BY u.created_at DESC`, 419 ) 420 .all(); 421}