馃 distributed transcription service thistle.dunkirk.sh
1import { Database } from "bun:sqlite"; 2 3// Use test database when NODE_ENV is test 4const dbPath = 5 process.env.NODE_ENV === "test" ? "thistle.test.db" : "thistle.db"; 6export const db = new Database(dbPath); 7 8console.log(`[Database] Using database: ${dbPath}`); 9 10// Schema version tracking 11db.run(` 12 CREATE TABLE IF NOT EXISTS schema_migrations ( 13 version INTEGER PRIMARY KEY, 14 applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 15 ) 16`); 17 18const migrations = [ 19 { 20 version: 1, 21 name: "Initial schema with all tables and constraints", 22 sql: ` 23 -- Users table 24 CREATE TABLE IF NOT EXISTS users ( 25 id INTEGER PRIMARY KEY AUTOINCREMENT, 26 email TEXT UNIQUE NOT NULL, 27 password_hash TEXT, 28 name TEXT, 29 avatar TEXT DEFAULT 'd', 30 role TEXT NOT NULL DEFAULT 'user', 31 last_login INTEGER, 32 email_verified BOOLEAN DEFAULT 0, 33 email_notifications_enabled BOOLEAN DEFAULT 1, 34 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 35 ); 36 37 CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 38 CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login); 39 CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified); 40 41 -- Sessions table 42 CREATE TABLE IF NOT EXISTS sessions ( 43 id TEXT PRIMARY KEY, 44 user_id INTEGER NOT NULL, 45 ip_address TEXT, 46 user_agent TEXT, 47 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 48 expires_at INTEGER NOT NULL, 49 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 50 ); 51 52 CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); 53 CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); 54 55 -- Passkeys table 56 CREATE TABLE IF NOT EXISTS passkeys ( 57 id TEXT PRIMARY KEY, 58 user_id INTEGER NOT NULL, 59 credential_id TEXT NOT NULL UNIQUE, 60 public_key TEXT NOT NULL, 61 counter INTEGER NOT NULL DEFAULT 0, 62 transports TEXT, 63 name TEXT, 64 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 65 last_used_at INTEGER, 66 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 67 ); 68 69 CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id); 70 CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id); 71 72 -- Rate limiting table 73 CREATE TABLE IF NOT EXISTS rate_limit_attempts ( 74 id INTEGER PRIMARY KEY AUTOINCREMENT, 75 key TEXT NOT NULL, 76 timestamp INTEGER NOT NULL 77 ); 78 79 CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp); 80 81 -- Classes table 82 CREATE TABLE IF NOT EXISTS classes ( 83 id TEXT PRIMARY KEY, 84 course_code TEXT NOT NULL, 85 name TEXT NOT NULL, 86 professor TEXT NOT NULL, 87 semester TEXT NOT NULL, 88 year INTEGER NOT NULL, 89 archived BOOLEAN DEFAULT 0, 90 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 91 ); 92 93 CREATE INDEX IF NOT EXISTS idx_classes_semester_year ON classes(semester, year); 94 CREATE INDEX IF NOT EXISTS idx_classes_archived ON classes(archived); 95 CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code); 96 97 -- Class members table 98 CREATE TABLE IF NOT EXISTS class_members ( 99 class_id TEXT NOT NULL, 100 user_id INTEGER NOT NULL, 101 enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 102 PRIMARY KEY (class_id, user_id), 103 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 104 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 105 ); 106 107 CREATE INDEX IF NOT EXISTS idx_class_members_user_id ON class_members(user_id); 108 CREATE INDEX IF NOT EXISTS idx_class_members_class_id ON class_members(class_id); 109 110 -- Meeting times table 111 CREATE TABLE IF NOT EXISTS meeting_times ( 112 id TEXT PRIMARY KEY, 113 class_id TEXT NOT NULL, 114 label TEXT NOT NULL, 115 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 116 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 117 ); 118 119 CREATE INDEX IF NOT EXISTS idx_meeting_times_class_id ON meeting_times(class_id); 120 121 -- Transcriptions table 122 CREATE TABLE IF NOT EXISTS transcriptions ( 123 id TEXT PRIMARY KEY, 124 user_id INTEGER NOT NULL, 125 class_id TEXT, 126 meeting_time_id TEXT, 127 filename TEXT NOT NULL, 128 original_filename TEXT NOT NULL, 129 status TEXT NOT NULL DEFAULT 'pending', 130 progress INTEGER NOT NULL DEFAULT 0, 131 error_message TEXT, 132 whisper_job_id TEXT, 133 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 134 updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 135 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 136 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 137 FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL 138 ); 139 140 CREATE INDEX IF NOT EXISTS idx_transcriptions_user_id ON transcriptions(user_id); 141 CREATE INDEX IF NOT EXISTS idx_transcriptions_class_id ON transcriptions(class_id); 142 CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status); 143 CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id); 144 CREATE INDEX IF NOT EXISTS idx_transcriptions_meeting_time_id ON transcriptions(meeting_time_id); 145 146 -- Class waitlist table 147 CREATE TABLE IF NOT EXISTS class_waitlist ( 148 id TEXT PRIMARY KEY, 149 user_id INTEGER NOT NULL, 150 course_code TEXT NOT NULL, 151 course_name TEXT NOT NULL, 152 professor TEXT NOT NULL, 153 semester TEXT NOT NULL, 154 year INTEGER NOT NULL, 155 meeting_times TEXT, 156 additional_info TEXT, 157 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 158 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 159 ); 160 161 CREATE INDEX IF NOT EXISTS idx_waitlist_user_id ON class_waitlist(user_id); 162 CREATE INDEX IF NOT EXISTS idx_waitlist_course_code ON class_waitlist(course_code); 163 164 -- Subscriptions table 165 CREATE TABLE IF NOT EXISTS subscriptions ( 166 id TEXT PRIMARY KEY, 167 user_id INTEGER NOT NULL, 168 customer_id TEXT NOT NULL, 169 status TEXT NOT NULL, 170 current_period_start INTEGER, 171 current_period_end INTEGER, 172 cancel_at_period_end BOOLEAN DEFAULT 0, 173 canceled_at INTEGER, 174 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 175 updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 176 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 177 ); 178 179 CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); 180 CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); 181 CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id); 182 183 -- Email verification tokens table 184 CREATE TABLE IF NOT EXISTS email_verification_tokens ( 185 id TEXT PRIMARY KEY, 186 user_id INTEGER NOT NULL, 187 token TEXT NOT NULL UNIQUE, 188 expires_at INTEGER NOT NULL, 189 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 190 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 191 ); 192 193 CREATE INDEX IF NOT EXISTS idx_verification_tokens_user_id ON email_verification_tokens(user_id); 194 CREATE INDEX IF NOT EXISTS idx_verification_tokens_token ON email_verification_tokens(token); 195 196 -- Password reset tokens table 197 CREATE TABLE IF NOT EXISTS password_reset_tokens ( 198 id TEXT PRIMARY KEY, 199 user_id INTEGER NOT NULL, 200 token TEXT NOT NULL UNIQUE, 201 expires_at INTEGER NOT NULL, 202 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 203 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 204 ); 205 206 CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); 207 CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token); 208 209 -- Email change tokens table 210 CREATE TABLE IF NOT EXISTS email_change_tokens ( 211 id TEXT PRIMARY KEY, 212 user_id INTEGER NOT NULL, 213 new_email TEXT NOT NULL, 214 token TEXT NOT NULL UNIQUE, 215 expires_at INTEGER NOT NULL, 216 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 217 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 218 ); 219 220 CREATE INDEX IF NOT EXISTS idx_email_change_tokens_user_id ON email_change_tokens(user_id); 221 CREATE INDEX IF NOT EXISTS idx_email_change_tokens_token ON email_change_tokens(token); 222 223 -- Create ghost user for deleted accounts 224 INSERT OR IGNORE INTO users (id, email, password_hash, name, avatar, role, created_at) 225 VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '馃懟', 'user', strftime('%s', 'now')); 226 `, 227 }, 228 { 229 version: 2, 230 name: "Add sections support to classes and class members", 231 sql: ` 232 -- Add section_number to classes (nullable for existing classes) 233 ALTER TABLE classes ADD COLUMN section_number TEXT; 234 235 -- Add section_id to class_members (nullable - NULL means default section) 236 ALTER TABLE class_members ADD COLUMN section_id TEXT; 237 238 -- Create sections table to track all available sections for a class 239 CREATE TABLE IF NOT EXISTS class_sections ( 240 id TEXT PRIMARY KEY, 241 class_id TEXT NOT NULL, 242 section_number TEXT NOT NULL, 243 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 244 FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 245 UNIQUE(class_id, section_number) 246 ); 247 248 CREATE INDEX IF NOT EXISTS idx_class_sections_class_id ON class_sections(class_id); 249 250 -- Add section_id to transcriptions to track which section uploaded it 251 ALTER TABLE transcriptions ADD COLUMN section_id TEXT; 252 253 CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id); 254 `, 255 }, 256 { 257 version: 3, 258 name: "Add voting system for collaborative recording selection", 259 sql: ` 260 -- Add vote count to transcriptions 261 ALTER TABLE transcriptions ADD COLUMN vote_count INTEGER NOT NULL DEFAULT 0; 262 263 -- Add auto-submitted flag to track if transcription was auto-selected 264 ALTER TABLE transcriptions ADD COLUMN auto_submitted BOOLEAN DEFAULT 0; 265 266 -- Create votes table to track who voted for which recording 267 CREATE TABLE IF NOT EXISTS recording_votes ( 268 id TEXT PRIMARY KEY, 269 transcription_id TEXT NOT NULL, 270 user_id INTEGER NOT NULL, 271 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 272 FOREIGN KEY (transcription_id) REFERENCES transcriptions(id) ON DELETE CASCADE, 273 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 274 UNIQUE(transcription_id, user_id) 275 ); 276 277 CREATE INDEX IF NOT EXISTS idx_recording_votes_transcription_id ON recording_votes(transcription_id); 278 CREATE INDEX IF NOT EXISTS idx_recording_votes_user_id ON recording_votes(user_id); 279 `, 280 }, 281]; 282 283function getCurrentVersion(): number { 284 const result = db 285 .query<{ version: number }, []>( 286 "SELECT MAX(version) as version FROM schema_migrations", 287 ) 288 .get(); 289 return result?.version ?? 0; 290} 291 292function applyMigration( 293 version: number, 294 sql: string, 295 index: number, 296 total: number, 297) { 298 const current = getCurrentVersion(); 299 if (current >= version) return; 300 301 const isTTY = typeof process !== "undefined" && process.stdout?.isTTY; 302 const startMsg = `Applying migration ${index + 1} of ${total}`; 303 304 if (isTTY) { 305 process.stdout.write(`${startMsg}...`); 306 const start = performance.now(); 307 db.run(sql); 308 db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); 309 const duration = Math.round(performance.now() - start); 310 process.stdout.write(`\r${startMsg} (${duration}ms)\n`); 311 } else { 312 console.log(startMsg); 313 db.run(sql); 314 db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); 315 } 316} 317 318// Apply all migrations 319const current = getCurrentVersion(); 320const pending = migrations.filter((m) => m.version > current); 321 322for (const [index, migration] of pending.entries()) { 323 applyMigration(migration.version, migration.sql, index, pending.length); 324} 325 326export default db;