馃 distributed transcription service
thistle.dunkirk.sh
1import db from "./db/schema";
2import {
3 authenticateUser,
4 cleanupExpiredSessions,
5 createSession,
6 createUser,
7 deleteAllUserSessions,
8 deleteSession,
9 deleteSessionById,
10 deleteTranscription,
11 deleteUser,
12 getAllTranscriptions,
13 getAllUsersWithStats,
14 getSession,
15 getSessionFromRequest,
16 getSessionsForUser,
17 getUserByEmail,
18 getUserBySession,
19 getUserSessionsForUser,
20 type UserRole,
21 updateUserAvatar,
22 updateUserEmail,
23 updateUserEmailAddress,
24 updateUserName,
25 updateUserPassword,
26 updateUserRole,
27} from "./lib/auth";
28import {
29 addToWaitlist,
30 createClass,
31 createMeetingTime,
32 deleteClass,
33 deleteMeetingTime,
34 deleteWaitlistEntry,
35 enrollUserInClass,
36 getAllWaitlistEntries,
37 getClassById,
38 getClassesForUser,
39 getClassMembers,
40 getMeetingTimesForClass,
41 getTranscriptionsForClass,
42 isUserEnrolledInClass,
43 joinClass,
44 removeUserFromClass,
45 searchClassesByCourseCode,
46 toggleClassArchive,
47 updateMeetingTime,
48} from "./lib/classes";
49import { handleError, ValidationErrors } from "./lib/errors";
50import {
51 requireAdmin,
52 requireAuth,
53 requireSubscription,
54} from "./lib/middleware";
55import {
56 createAuthenticationOptions,
57 createRegistrationOptions,
58 deletePasskey,
59 getPasskeysForUser,
60 updatePasskeyName,
61 verifyAndAuthenticatePasskey,
62 verifyAndCreatePasskey,
63} from "./lib/passkey";
64import { enforceRateLimit } from "./lib/rate-limit";
65import { getTranscriptVTT } from "./lib/transcript-storage";
66import {
67 MAX_FILE_SIZE,
68 TranscriptionEventEmitter,
69 type TranscriptionUpdate,
70 WhisperServiceManager,
71} from "./lib/transcription";
72import adminHTML from "./pages/admin.html";
73import checkoutHTML from "./pages/checkout.html";
74import classHTML from "./pages/class.html";
75import classesHTML from "./pages/classes.html";
76import indexHTML from "./pages/index.html";
77import settingsHTML from "./pages/settings.html";
78import transcribeHTML from "./pages/transcribe.html";
79
80// Environment variables
81const WHISPER_SERVICE_URL =
82 process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
83
84// Create uploads and transcripts directories if they don't exist
85await Bun.write("./uploads/.gitkeep", "");
86await Bun.write("./transcripts/.gitkeep", "");
87
88// Initialize transcription system
89console.log(
90 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`,
91);
92const transcriptionEvents = new TranscriptionEventEmitter();
93const whisperService = new WhisperServiceManager(
94 WHISPER_SERVICE_URL,
95 db,
96 transcriptionEvents,
97);
98
99// Clean up expired sessions every hour
100setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
101
102// Helper function to sync user subscriptions from Polar
103async function syncUserSubscriptionsFromPolar(
104 userId: number,
105 email: string,
106): Promise<void> {
107 try {
108 const { polar } = await import("./lib/polar");
109
110 // Search for customer by email
111 const customers = await polar.customers.list({
112 organizationId: process.env.POLAR_ORGANIZATION_ID,
113 query: email,
114 });
115
116 if (!customers.result.items || customers.result.items.length === 0) {
117 console.log(`[Sync] No Polar customer found for ${email}`);
118 return;
119 }
120
121 const customer = customers.result.items[0];
122
123 // Get all subscriptions for this customer
124 const subscriptions = await polar.subscriptions.list({
125 customerId: customer.id,
126 });
127
128 if (!subscriptions.result.items || subscriptions.result.items.length === 0) {
129 console.log(`[Sync] No subscriptions found for customer ${customer.id}`);
130 return;
131 }
132
133 // Update each subscription in the database
134 for (const subscription of subscriptions.result.items) {
135 db.run(
136 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
137 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
138 ON CONFLICT(id) DO UPDATE SET
139 user_id = excluded.user_id,
140 status = excluded.status,
141 current_period_start = excluded.current_period_start,
142 current_period_end = excluded.current_period_end,
143 cancel_at_period_end = excluded.cancel_at_period_end,
144 canceled_at = excluded.canceled_at,
145 updated_at = excluded.updated_at`,
146 [
147 subscription.id,
148 userId,
149 subscription.customerId,
150 subscription.status,
151 subscription.currentPeriodStart
152 ? Math.floor(
153 new Date(subscription.currentPeriodStart).getTime() / 1000,
154 )
155 : null,
156 subscription.currentPeriodEnd
157 ? Math.floor(
158 new Date(subscription.currentPeriodEnd).getTime() / 1000,
159 )
160 : null,
161 subscription.cancelAtPeriodEnd ? 1 : 0,
162 subscription.canceledAt
163 ? Math.floor(new Date(subscription.canceledAt).getTime() / 1000)
164 : null,
165 Math.floor(Date.now() / 1000),
166 ],
167 );
168 }
169
170 console.log(
171 `[Sync] Linked ${subscriptions.result.items.length} subscription(s) to user ${userId} (${email})`,
172 );
173 } catch (error) {
174 console.error(
175 `[Sync] Failed to sync subscriptions for ${email}:`,
176 error instanceof Error ? error.message : "Unknown error",
177 );
178 // Don't throw - registration should succeed even if sync fails
179 }
180}
181
182
183// Sync with Whisper DB on startup
184try {
185 await whisperService.syncWithWhisper();
186 console.log("[Transcription] Successfully connected to Murmur");
187} catch (error) {
188 console.warn(
189 "[Transcription] Murmur unavailable at startup:",
190 error instanceof Error ? error.message : "Unknown error",
191 );
192}
193
194// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
195setInterval(
196 async () => {
197 try {
198 await whisperService.syncWithWhisper();
199 } catch (error) {
200 console.warn(
201 "[Sync] Failed to sync with Murmur:",
202 error instanceof Error ? error.message : "Unknown error",
203 );
204 }
205 },
206 5 * 60 * 1000,
207);
208
209// Clean up stale files daily
210setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
211
212const server = Bun.serve({
213 port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000,
214 idleTimeout: 120, // 120 seconds for SSE connections
215 routes: {
216 "/": indexHTML,
217 "/admin": adminHTML,
218 "/checkout": checkoutHTML,
219 "/settings": settingsHTML,
220 "/transcribe": transcribeHTML,
221 "/classes": classesHTML,
222 "/classes/*": classHTML,
223 "/apple-touch-icon.png": Bun.file("./public/favicon/apple-touch-icon.png"),
224 "/favicon-32x32.png": Bun.file("./public/favicon/favicon-32x32.png"),
225 "/favicon-16x16.png": Bun.file("./public/favicon/favicon-16x16.png"),
226 "/site.webmanifest": Bun.file("./public/favicon/site.webmanifest"),
227 "/favicon.ico": Bun.file("./public/favicon/favicon.ico"),
228 "/api/auth/register": {
229 POST: async (req) => {
230 try {
231 // Rate limiting
232 const rateLimitError = enforceRateLimit(req, "register", {
233 ip: { max: 5, windowSeconds: 60 * 60 },
234 });
235 if (rateLimitError) return rateLimitError;
236
237 const body = await req.json();
238 const { email, password, name } = body;
239 if (!email || !password) {
240 return Response.json(
241 { error: "Email and password required" },
242 { status: 400 },
243 );
244 }
245 // Password is client-side hashed (PBKDF2), should be 64 char hex
246 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
247 return Response.json(
248 { error: "Invalid password format" },
249 { status: 400 },
250 );
251 }
252 const user = await createUser(email, password, name);
253
254 // Attempt to sync existing Polar subscriptions
255 syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => {
256 // Silent fail - don't block registration
257 });
258
259 const ipAddress =
260 req.headers.get("x-forwarded-for") ??
261 req.headers.get("x-real-ip") ??
262 "unknown";
263 const userAgent = req.headers.get("user-agent") ?? "unknown";
264 const sessionId = createSession(user.id, ipAddress, userAgent);
265 return Response.json(
266 { user: { id: user.id, email: user.email } },
267 {
268 headers: {
269 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
270 },
271 },
272 );
273 } catch (err: unknown) {
274 const error = err as { message?: string };
275 if (error.message?.includes("UNIQUE constraint failed")) {
276 return Response.json(
277 { error: "Email already registered" },
278 { status: 400 },
279 );
280 }
281 return Response.json(
282 { error: "Registration failed" },
283 { status: 500 },
284 );
285 }
286 },
287 },
288 "/api/auth/login": {
289 POST: async (req) => {
290 try {
291 const body = await req.json();
292 const { email, password } = body;
293 if (!email || !password) {
294 return Response.json(
295 { error: "Email and password required" },
296 { status: 400 },
297 );
298 }
299
300 // Rate limiting: Per IP and per account
301 const rateLimitError = enforceRateLimit(req, "login", {
302 ip: { max: 10, windowSeconds: 15 * 60 },
303 account: { max: 5, windowSeconds: 15 * 60, email },
304 });
305 if (rateLimitError) return rateLimitError;
306
307 // Password is client-side hashed (PBKDF2), should be 64 char hex
308 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
309 return Response.json(
310 { error: "Invalid password format" },
311 { status: 400 },
312 );
313 }
314 const user = await authenticateUser(email, password);
315 if (!user) {
316 return Response.json(
317 { error: "Invalid email or password" },
318 { status: 401 },
319 );
320 }
321 const ipAddress =
322 req.headers.get("x-forwarded-for") ??
323 req.headers.get("x-real-ip") ??
324 "unknown";
325 const userAgent = req.headers.get("user-agent") ?? "unknown";
326 const sessionId = createSession(user.id, ipAddress, userAgent);
327 return Response.json(
328 { user: { id: user.id, email: user.email } },
329 {
330 headers: {
331 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
332 },
333 },
334 );
335 } catch {
336 return Response.json({ error: "Login failed" }, { status: 500 });
337 }
338 },
339 },
340 "/api/auth/logout": {
341 POST: async (req) => {
342 const sessionId = getSessionFromRequest(req);
343 if (sessionId) {
344 deleteSession(sessionId);
345 }
346 return Response.json(
347 { success: true },
348 {
349 headers: {
350 "Set-Cookie":
351 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
352 },
353 },
354 );
355 },
356 },
357 "/api/auth/me": {
358 GET: (req) => {
359 const sessionId = getSessionFromRequest(req);
360 if (!sessionId) {
361 return Response.json({ error: "Not authenticated" }, { status: 401 });
362 }
363 const user = getUserBySession(sessionId);
364 if (!user) {
365 return Response.json({ error: "Invalid session" }, { status: 401 });
366 }
367
368 // Check subscription status
369 const subscription = db
370 .query<{ status: string }, [number]>(
371 "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
372 )
373 .get(user.id);
374
375 return Response.json({
376 email: user.email,
377 name: user.name,
378 avatar: user.avatar,
379 created_at: user.created_at,
380 role: user.role,
381 has_subscription: !!subscription,
382 });
383 },
384 },
385 "/api/passkeys/register/options": {
386 POST: async (req) => {
387 try {
388 const user = requireAuth(req);
389 const options = await createRegistrationOptions(user);
390 return Response.json(options);
391 } catch (err) {
392 return handleError(err);
393 }
394 },
395 },
396 "/api/passkeys/register/verify": {
397 POST: async (req) => {
398 try {
399 const _user = requireAuth(req);
400 const body = await req.json();
401 const { response: credentialResponse, challenge, name } = body;
402
403 const passkey = await verifyAndCreatePasskey(
404 credentialResponse,
405 challenge,
406 name,
407 );
408
409 return Response.json({
410 success: true,
411 passkey: {
412 id: passkey.id,
413 name: passkey.name,
414 created_at: passkey.created_at,
415 },
416 });
417 } catch (err) {
418 return handleError(err);
419 }
420 },
421 },
422 "/api/passkeys/authenticate/options": {
423 POST: async (req) => {
424 try {
425 const body = await req.json();
426 const { email } = body;
427
428 const options = await createAuthenticationOptions(email);
429 return Response.json(options);
430 } catch (err) {
431 return handleError(err);
432 }
433 },
434 },
435 "/api/passkeys/authenticate/verify": {
436 POST: async (req) => {
437 try {
438 const body = await req.json();
439 const { response: credentialResponse, challenge } = body;
440
441 const result = await verifyAndAuthenticatePasskey(
442 credentialResponse,
443 challenge,
444 );
445
446 if ("error" in result) {
447 return new Response(JSON.stringify({ error: result.error }), {
448 status: 401,
449 });
450 }
451
452 const { user } = result;
453
454 // Create session
455 const ipAddress =
456 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
457 req.headers.get("x-real-ip") ||
458 "unknown";
459 const userAgent = req.headers.get("user-agent") || "unknown";
460 const sessionId = createSession(user.id, ipAddress, userAgent);
461
462 return Response.json(
463 {
464 email: user.email,
465 name: user.name,
466 avatar: user.avatar,
467 created_at: user.created_at,
468 role: user.role,
469 },
470 {
471 headers: {
472 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
473 },
474 },
475 );
476 } catch (err) {
477 return handleError(err);
478 }
479 },
480 },
481 "/api/passkeys": {
482 GET: async (req) => {
483 try {
484 const user = requireAuth(req);
485 const passkeys = getPasskeysForUser(user.id);
486 return Response.json({
487 passkeys: passkeys.map((p) => ({
488 id: p.id,
489 name: p.name,
490 created_at: p.created_at,
491 last_used_at: p.last_used_at,
492 })),
493 });
494 } catch (err) {
495 return handleError(err);
496 }
497 },
498 },
499 "/api/passkeys/:id": {
500 PUT: async (req) => {
501 try {
502 const user = requireAuth(req);
503 const body = await req.json();
504 const { name } = body;
505 const passkeyId = req.params.id;
506
507 if (!name) {
508 return Response.json({ error: "Name required" }, { status: 400 });
509 }
510
511 updatePasskeyName(passkeyId, user.id, name);
512 return Response.json({ success: true });
513 } catch (err) {
514 return handleError(err);
515 }
516 },
517 DELETE: async (req) => {
518 try {
519 const user = requireAuth(req);
520 const passkeyId = req.params.id;
521 deletePasskey(passkeyId, user.id);
522 return Response.json({ success: true });
523 } catch (err) {
524 return handleError(err);
525 }
526 },
527 },
528 "/api/sessions": {
529 GET: (req) => {
530 const sessionId = getSessionFromRequest(req);
531 if (!sessionId) {
532 return Response.json({ error: "Not authenticated" }, { status: 401 });
533 }
534 const user = getUserBySession(sessionId);
535 if (!user) {
536 return Response.json({ error: "Invalid session" }, { status: 401 });
537 }
538 const sessions = getUserSessionsForUser(user.id);
539 return Response.json({
540 sessions: sessions.map((s) => ({
541 id: s.id,
542 ip_address: s.ip_address,
543 user_agent: s.user_agent,
544 created_at: s.created_at,
545 expires_at: s.expires_at,
546 is_current: s.id === sessionId,
547 })),
548 });
549 },
550 DELETE: async (req) => {
551 const currentSessionId = getSessionFromRequest(req);
552 if (!currentSessionId) {
553 return Response.json({ error: "Not authenticated" }, { status: 401 });
554 }
555 const user = getUserBySession(currentSessionId);
556 if (!user) {
557 return Response.json({ error: "Invalid session" }, { status: 401 });
558 }
559 const body = await req.json();
560 const targetSessionId = body.sessionId;
561 if (!targetSessionId) {
562 return Response.json(
563 { error: "Session ID required" },
564 { status: 400 },
565 );
566 }
567 // Prevent deleting current session
568 if (targetSessionId === currentSessionId) {
569 return Response.json(
570 { error: "Cannot kill current session. Use logout instead." },
571 { status: 400 },
572 );
573 }
574 // Verify the session belongs to the user
575 const targetSession = getSession(targetSessionId);
576 if (!targetSession || targetSession.user_id !== user.id) {
577 return Response.json({ error: "Session not found" }, { status: 404 });
578 }
579 deleteSession(targetSessionId);
580 return Response.json({ success: true });
581 },
582 },
583 "/api/user": {
584 DELETE: async (req) => {
585 const sessionId = getSessionFromRequest(req);
586 if (!sessionId) {
587 return Response.json({ error: "Not authenticated" }, { status: 401 });
588 }
589 const user = getUserBySession(sessionId);
590 if (!user) {
591 return Response.json({ error: "Invalid session" }, { status: 401 });
592 }
593
594 // Rate limiting
595 const rateLimitError = enforceRateLimit(req, "delete-user", {
596 ip: { max: 3, windowSeconds: 60 * 60 },
597 });
598 if (rateLimitError) return rateLimitError;
599
600 await deleteUser(user.id);
601 return Response.json(
602 { success: true },
603 {
604 headers: {
605 "Set-Cookie":
606 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
607 },
608 },
609 );
610 },
611 },
612 "/api/user/email": {
613 PUT: async (req) => {
614 const sessionId = getSessionFromRequest(req);
615 if (!sessionId) {
616 return Response.json({ error: "Not authenticated" }, { status: 401 });
617 }
618 const user = getUserBySession(sessionId);
619 if (!user) {
620 return Response.json({ error: "Invalid session" }, { status: 401 });
621 }
622
623 // Rate limiting
624 const rateLimitError = enforceRateLimit(req, "update-email", {
625 ip: { max: 5, windowSeconds: 60 * 60 },
626 });
627 if (rateLimitError) return rateLimitError;
628
629 const body = await req.json();
630 const { email } = body;
631 if (!email) {
632 return Response.json({ error: "Email required" }, { status: 400 });
633 }
634 try {
635 updateUserEmail(user.id, email);
636 return Response.json({ success: true });
637 } catch (err: unknown) {
638 const error = err as { message?: string };
639 if (error.message?.includes("UNIQUE constraint failed")) {
640 return Response.json(
641 { error: "Email already in use" },
642 { status: 400 },
643 );
644 }
645 return Response.json(
646 { error: "Failed to update email" },
647 { status: 500 },
648 );
649 }
650 },
651 },
652 "/api/user/password": {
653 PUT: async (req) => {
654 const sessionId = getSessionFromRequest(req);
655 if (!sessionId) {
656 return Response.json({ error: "Not authenticated" }, { status: 401 });
657 }
658 const user = getUserBySession(sessionId);
659 if (!user) {
660 return Response.json({ error: "Invalid session" }, { status: 401 });
661 }
662
663 // Rate limiting
664 const rateLimitError = enforceRateLimit(req, "update-password", {
665 ip: { max: 5, windowSeconds: 60 * 60 },
666 });
667 if (rateLimitError) return rateLimitError;
668
669 const body = await req.json();
670 const { password } = body;
671 if (!password) {
672 return Response.json({ error: "Password required" }, { status: 400 });
673 }
674 // Password is client-side hashed (PBKDF2), should be 64 char hex
675 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
676 return Response.json(
677 { error: "Invalid password format" },
678 { status: 400 },
679 );
680 }
681 try {
682 await updateUserPassword(user.id, password);
683 return Response.json({ success: true });
684 } catch {
685 return Response.json(
686 { error: "Failed to update password" },
687 { status: 500 },
688 );
689 }
690 },
691 },
692 "/api/user/name": {
693 PUT: async (req) => {
694 const sessionId = getSessionFromRequest(req);
695 if (!sessionId) {
696 return Response.json({ error: "Not authenticated" }, { status: 401 });
697 }
698 const user = getUserBySession(sessionId);
699 if (!user) {
700 return Response.json({ error: "Invalid session" }, { status: 401 });
701 }
702 const body = await req.json();
703 const { name } = body;
704 if (!name) {
705 return Response.json({ error: "Name required" }, { status: 400 });
706 }
707 try {
708 updateUserName(user.id, name);
709 return Response.json({ success: true });
710 } catch {
711 return Response.json(
712 { error: "Failed to update name" },
713 { status: 500 },
714 );
715 }
716 },
717 },
718 "/api/user/avatar": {
719 PUT: async (req) => {
720 const sessionId = getSessionFromRequest(req);
721 if (!sessionId) {
722 return Response.json({ error: "Not authenticated" }, { status: 401 });
723 }
724 const user = getUserBySession(sessionId);
725 if (!user) {
726 return Response.json({ error: "Invalid session" }, { status: 401 });
727 }
728 const body = await req.json();
729 const { avatar } = body;
730 if (!avatar) {
731 return Response.json({ error: "Avatar required" }, { status: 400 });
732 }
733 try {
734 updateUserAvatar(user.id, avatar);
735 return Response.json({ success: true });
736 } catch {
737 return Response.json(
738 { error: "Failed to update avatar" },
739 { status: 500 },
740 );
741 }
742 },
743 },
744 "/api/billing/checkout": {
745 POST: async (req) => {
746 const sessionId = getSessionFromRequest(req);
747 if (!sessionId) {
748 return Response.json({ error: "Not authenticated" }, { status: 401 });
749 }
750 const user = getUserBySession(sessionId);
751 if (!user) {
752 return Response.json({ error: "Invalid session" }, { status: 401 });
753 }
754
755 try {
756 const { polar } = await import("./lib/polar");
757
758 const productId = process.env.POLAR_PRODUCT_ID;
759 if (!productId) {
760 return Response.json(
761 { error: "Product not configured" },
762 { status: 500 },
763 );
764 }
765
766 const successUrl = process.env.POLAR_SUCCESS_URL;
767 if (!successUrl) {
768 return Response.json(
769 { error: "Success URL not configured" },
770 { status: 500 },
771 );
772 }
773
774 const checkout = await polar.checkouts.create({
775 products: [productId],
776 successUrl,
777 customerEmail: user.email,
778 customerName: user.name ?? undefined,
779 metadata: {
780 userId: user.id.toString(),
781 },
782 });
783
784 return Response.json({ url: checkout.url });
785 } catch (error) {
786 console.error("Failed to create checkout:", error);
787 return Response.json(
788 { error: "Failed to create checkout session" },
789 { status: 500 },
790 );
791 }
792 },
793 },
794 "/api/billing/subscription": {
795 GET: async (req) => {
796 const sessionId = getSessionFromRequest(req);
797 if (!sessionId) {
798 return Response.json({ error: "Not authenticated" }, { status: 401 });
799 }
800 const user = getUserBySession(sessionId);
801 if (!user) {
802 return Response.json({ error: "Invalid session" }, { status: 401 });
803 }
804
805 try {
806 // Get subscription from database
807 const subscription = db
808 .query<
809 {
810 id: string;
811 status: string;
812 current_period_start: number | null;
813 current_period_end: number | null;
814 cancel_at_period_end: number;
815 canceled_at: number | null;
816 },
817 [number]
818 >(
819 "SELECT id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
820 )
821 .get(user.id);
822
823 if (!subscription) {
824 return Response.json({ subscription: null });
825 }
826
827 return Response.json({ subscription });
828 } catch (error) {
829 console.error("Failed to fetch subscription:", error);
830 return Response.json(
831 { error: "Failed to fetch subscription" },
832 { status: 500 },
833 );
834 }
835 },
836 },
837 "/api/billing/portal": {
838 POST: async (req) => {
839 const sessionId = getSessionFromRequest(req);
840 if (!sessionId) {
841 return Response.json({ error: "Not authenticated" }, { status: 401 });
842 }
843 const user = getUserBySession(sessionId);
844 if (!user) {
845 return Response.json({ error: "Invalid session" }, { status: 401 });
846 }
847
848 try {
849 const { polar } = await import("./lib/polar");
850
851 // Get subscription to find customer ID
852 const subscription = db
853 .query<
854 {
855 customer_id: string;
856 },
857 [number]
858 >(
859 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
860 )
861 .get(user.id);
862
863 if (!subscription || !subscription.customer_id) {
864 return Response.json(
865 { error: "No subscription found" },
866 { status: 404 },
867 );
868 }
869
870 // Create customer portal session
871 const session = await polar.customerSessions.create({
872 customerId: subscription.customer_id,
873 });
874
875 return Response.json({ url: session.customerPortalUrl });
876 } catch (error) {
877 console.error("Failed to create portal session:", error);
878 return Response.json(
879 { error: "Failed to create portal session" },
880 { status: 500 },
881 );
882 }
883 },
884 },
885 "/api/webhooks/polar": {
886 POST: async (req) => {
887 try {
888 const { validateEvent } = await import("@polar-sh/sdk/webhooks");
889
890 // Get raw body as string
891 const rawBody = await req.text();
892 const headers = Object.fromEntries(req.headers.entries());
893
894 // Validate webhook signature
895 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
896 if (!webhookSecret) {
897 console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured");
898 return Response.json(
899 { error: "Webhook secret not configured" },
900 { status: 500 },
901 );
902 }
903
904 const event = validateEvent(rawBody, headers, webhookSecret);
905
906 console.log(`[Webhook] Received event: ${event.type}`);
907
908 // Handle different event types
909 switch (event.type) {
910 case "subscription.updated": {
911 const { id, status, customerId, metadata } = event.data;
912 const userId = metadata?.userId
913 ? Number.parseInt(metadata.userId as string, 10)
914 : null;
915
916 if (!userId) {
917 console.warn("[Webhook] No userId in subscription metadata");
918 break;
919 }
920
921 // Upsert subscription
922 db.run(
923 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
924 VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
925 ON CONFLICT(id) DO UPDATE SET
926 status = excluded.status,
927 current_period_start = excluded.current_period_start,
928 current_period_end = excluded.current_period_end,
929 cancel_at_period_end = excluded.cancel_at_period_end,
930 canceled_at = excluded.canceled_at,
931 updated_at = strftime('%s', 'now')`,
932 [
933 id,
934 userId,
935 customerId,
936 status,
937 event.data.currentPeriodStart
938 ? Math.floor(
939 new Date(event.data.currentPeriodStart).getTime() /
940 1000,
941 )
942 : null,
943 event.data.currentPeriodEnd
944 ? Math.floor(
945 new Date(event.data.currentPeriodEnd).getTime() / 1000,
946 )
947 : null,
948 event.data.cancelAtPeriodEnd ? 1 : 0,
949 event.data.canceledAt
950 ? Math.floor(
951 new Date(event.data.canceledAt).getTime() / 1000,
952 )
953 : null,
954 ],
955 );
956
957 console.log(
958 `[Webhook] Updated subscription ${id} for user ${userId}`,
959 );
960 break;
961 }
962
963 default:
964 console.log(`[Webhook] Unhandled event type: ${event.type}`);
965 }
966
967 return Response.json({ received: true });
968 } catch (error) {
969 console.error("[Webhook] Error processing webhook:", error);
970 return Response.json(
971 { error: "Webhook processing failed" },
972 { status: 400 },
973 );
974 }
975 },
976 },
977 "/api/transcriptions/:id/stream": {
978 GET: async (req) => {
979 try {
980 const user = requireSubscription(req);
981 const transcriptionId = req.params.id;
982 // Verify ownership
983 const transcription = db
984 .query<{ id: string; user_id: number; status: string }, [string]>(
985 "SELECT id, user_id, status FROM transcriptions WHERE id = ?",
986 )
987 .get(transcriptionId);
988 if (!transcription || transcription.user_id !== user.id) {
989 return Response.json(
990 { error: "Transcription not found" },
991 { status: 404 },
992 );
993 }
994 // Event-driven SSE stream with reconnection support
995 const stream = new ReadableStream({
996 async start(controller) {
997 const encoder = new TextEncoder();
998 let isClosed = false;
999 let lastEventId = Math.floor(Date.now() / 1000);
1000
1001 const sendEvent = (data: Partial<TranscriptionUpdate>) => {
1002 if (isClosed) return;
1003 try {
1004 // Send event ID for reconnection support
1005 lastEventId = Math.floor(Date.now() / 1000);
1006 controller.enqueue(
1007 encoder.encode(
1008 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`,
1009 ),
1010 );
1011 } catch {
1012 // Controller already closed (client disconnected)
1013 isClosed = true;
1014 }
1015 };
1016
1017 const sendHeartbeat = () => {
1018 if (isClosed) return;
1019 try {
1020 controller.enqueue(encoder.encode(": heartbeat\n\n"));
1021 } catch {
1022 isClosed = true;
1023 }
1024 };
1025 // Send initial state from DB and file
1026 const current = db
1027 .query<
1028 {
1029 status: string;
1030 progress: number;
1031 },
1032 [string]
1033 >("SELECT status, progress FROM transcriptions WHERE id = ?")
1034 .get(transcriptionId);
1035 if (current) {
1036 sendEvent({
1037 status: current.status as TranscriptionUpdate["status"],
1038 progress: current.progress,
1039 });
1040 }
1041 // If already complete, close immediately
1042 if (
1043 current?.status === "completed" ||
1044 current?.status === "failed"
1045 ) {
1046 isClosed = true;
1047 controller.close();
1048 return;
1049 }
1050 // Send heartbeats every 2.5 seconds to keep connection alive
1051 const heartbeatInterval = setInterval(sendHeartbeat, 2500);
1052
1053 // Subscribe to EventEmitter for live updates
1054 const updateHandler = (data: TranscriptionUpdate) => {
1055 if (isClosed) return;
1056
1057 // Only send changed fields to save bandwidth
1058 const payload: Partial<TranscriptionUpdate> = {
1059 status: data.status,
1060 progress: data.progress,
1061 };
1062
1063 if (data.transcript !== undefined) {
1064 payload.transcript = data.transcript;
1065 }
1066 if (data.error_message !== undefined) {
1067 payload.error_message = data.error_message;
1068 }
1069
1070 sendEvent(payload);
1071
1072 // Close stream when done
1073 if (data.status === "completed" || data.status === "failed") {
1074 isClosed = true;
1075 clearInterval(heartbeatInterval);
1076 transcriptionEvents.off(transcriptionId, updateHandler);
1077 controller.close();
1078 }
1079 };
1080 transcriptionEvents.on(transcriptionId, updateHandler);
1081 // Cleanup on client disconnect
1082 return () => {
1083 isClosed = true;
1084 clearInterval(heartbeatInterval);
1085 transcriptionEvents.off(transcriptionId, updateHandler);
1086 };
1087 },
1088 });
1089 return new Response(stream, {
1090 headers: {
1091 "Content-Type": "text/event-stream",
1092 "Cache-Control": "no-cache",
1093 Connection: "keep-alive",
1094 },
1095 });
1096 } catch (error) {
1097 return handleError(error);
1098 }
1099 },
1100 },
1101 "/api/transcriptions/health": {
1102 GET: async () => {
1103 const isHealthy = await whisperService.checkHealth();
1104 return Response.json({ available: isHealthy });
1105 },
1106 },
1107 "/api/transcriptions/:id": {
1108 GET: async (req) => {
1109 try {
1110 const user = requireSubscription(req);
1111 const transcriptionId = req.params.id;
1112
1113 // Verify ownership or admin
1114 const transcription = db
1115 .query<
1116 {
1117 id: string;
1118 user_id: number;
1119 filename: string;
1120 original_filename: string;
1121 status: string;
1122 progress: number;
1123 created_at: number;
1124 },
1125 [string]
1126 >(
1127 "SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?",
1128 )
1129 .get(transcriptionId);
1130
1131 if (!transcription) {
1132 return Response.json(
1133 { error: "Transcription not found" },
1134 { status: 404 },
1135 );
1136 }
1137
1138 // Allow access if user owns it or is admin
1139 if (transcription.user_id !== user.id && user.role !== "admin") {
1140 return Response.json(
1141 { error: "Transcription not found" },
1142 { status: 404 },
1143 );
1144 }
1145
1146 if (transcription.status !== "completed") {
1147 return Response.json(
1148 { error: "Transcription not completed yet" },
1149 { status: 400 },
1150 );
1151 }
1152
1153 // Get format from query parameter
1154 const url = new URL(req.url);
1155 const format = url.searchParams.get("format");
1156
1157 // Return WebVTT format if requested
1158 if (format === "vtt") {
1159 const vttContent = await getTranscriptVTT(transcriptionId);
1160
1161 if (!vttContent) {
1162 return Response.json(
1163 { error: "VTT transcript not available" },
1164 { status: 404 },
1165 );
1166 }
1167
1168 return new Response(vttContent, {
1169 headers: {
1170 "Content-Type": "text/vtt",
1171 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`,
1172 },
1173 });
1174 }
1175
1176 // return info on transcript
1177 const transcript = {
1178 id: transcription.id,
1179 filename: transcription.original_filename,
1180 status: transcription.status,
1181 progress: transcription.progress,
1182 created_at: transcription.created_at,
1183 };
1184 return new Response(JSON.stringify(transcript), {
1185 headers: {
1186 "Content-Type": "application/json",
1187 },
1188 });
1189 } catch (error) {
1190 return handleError(error);
1191 }
1192 },
1193 },
1194 "/api/transcriptions/:id/audio": {
1195 GET: async (req) => {
1196 try {
1197 const user = requireSubscription(req);
1198 const transcriptionId = req.params.id;
1199
1200 // Verify ownership or admin
1201 const transcription = db
1202 .query<
1203 {
1204 id: string;
1205 user_id: number;
1206 filename: string;
1207 status: string;
1208 },
1209 [string]
1210 >(
1211 "SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?",
1212 )
1213 .get(transcriptionId);
1214
1215 if (!transcription) {
1216 return Response.json(
1217 { error: "Transcription not found" },
1218 { status: 404 },
1219 );
1220 }
1221
1222 // Allow access if user owns it or is admin
1223 if (transcription.user_id !== user.id && user.role !== "admin") {
1224 return Response.json(
1225 { error: "Transcription not found" },
1226 { status: 404 },
1227 );
1228 }
1229
1230 // For pending recordings, audio file exists even though transcription isn't complete
1231 // Allow audio access for pending and completed statuses
1232 if (
1233 transcription.status !== "completed" &&
1234 transcription.status !== "pending"
1235 ) {
1236 return Response.json(
1237 { error: "Audio not available yet" },
1238 { status: 400 },
1239 );
1240 }
1241
1242 // Serve the audio file with range request support
1243 const filePath = `./uploads/${transcription.filename}`;
1244 const file = Bun.file(filePath);
1245
1246 if (!(await file.exists())) {
1247 return Response.json(
1248 { error: "Audio file not found" },
1249 { status: 404 },
1250 );
1251 }
1252
1253 const fileSize = file.size;
1254 const range = req.headers.get("range");
1255
1256 // Handle range requests for seeking
1257 if (range) {
1258 const parts = range.replace(/bytes=/, "").split("-");
1259 const start = Number.parseInt(parts[0] || "0", 10);
1260 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1;
1261 const chunkSize = end - start + 1;
1262
1263 const fileSlice = file.slice(start, end + 1);
1264
1265 return new Response(fileSlice, {
1266 status: 206,
1267 headers: {
1268 "Content-Range": `bytes ${start}-${end}/${fileSize}`,
1269 "Accept-Ranges": "bytes",
1270 "Content-Length": chunkSize.toString(),
1271 "Content-Type": file.type || "audio/mpeg",
1272 },
1273 });
1274 }
1275
1276 // No range request, send entire file
1277 return new Response(file, {
1278 headers: {
1279 "Content-Type": file.type || "audio/mpeg",
1280 "Accept-Ranges": "bytes",
1281 "Content-Length": fileSize.toString(),
1282 },
1283 });
1284 } catch (error) {
1285 return handleError(error);
1286 }
1287 },
1288 },
1289 "/api/transcriptions": {
1290 GET: async (req) => {
1291 try {
1292 const user = requireSubscription(req);
1293
1294 const transcriptions = db
1295 .query<
1296 {
1297 id: string;
1298 filename: string;
1299 original_filename: string;
1300 class_id: string | null;
1301 status: string;
1302 progress: number;
1303 created_at: number;
1304 },
1305 [number]
1306 >(
1307 "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
1308 )
1309 .all(user.id);
1310
1311 // Load transcripts from files for completed jobs
1312 const jobs = await Promise.all(
1313 transcriptions.map(async (t) => {
1314 return {
1315 id: t.id,
1316 filename: t.original_filename,
1317 class_id: t.class_id,
1318 status: t.status,
1319 progress: t.progress,
1320 created_at: t.created_at,
1321 };
1322 }),
1323 );
1324
1325 return Response.json({ jobs });
1326 } catch (error) {
1327 return handleError(error);
1328 }
1329 },
1330 POST: async (req) => {
1331 try {
1332 const user = requireSubscription(req);
1333
1334 const formData = await req.formData();
1335 const file = formData.get("audio") as File;
1336 const classId = formData.get("class_id") as string | null;
1337 const meetingTimeId = formData.get("meeting_time_id") as
1338 | string
1339 | null;
1340
1341 if (!file) throw ValidationErrors.missingField("audio");
1342
1343 // If class_id provided, verify user is enrolled (or admin)
1344 if (classId) {
1345 const enrolled = isUserEnrolledInClass(user.id, classId);
1346 if (!enrolled && user.role !== "admin") {
1347 return Response.json(
1348 { error: "Not enrolled in this class" },
1349 { status: 403 },
1350 );
1351 }
1352
1353 // Verify class exists
1354 const classInfo = getClassById(classId);
1355 if (!classInfo) {
1356 return Response.json(
1357 { error: "Class not found" },
1358 { status: 404 },
1359 );
1360 }
1361
1362 // Check if class is archived
1363 if (classInfo.archived) {
1364 return Response.json(
1365 { error: "Cannot upload to archived class" },
1366 { status: 400 },
1367 );
1368 }
1369 }
1370
1371 // Validate file type
1372 const fileExtension = file.name.split(".").pop()?.toLowerCase();
1373 const allowedExtensions = [
1374 "mp3",
1375 "wav",
1376 "m4a",
1377 "aac",
1378 "ogg",
1379 "webm",
1380 "flac",
1381 "mp4",
1382 ];
1383 const isAudioType =
1384 file.type.startsWith("audio/") || file.type === "video/mp4";
1385 const isAudioExtension =
1386 fileExtension && allowedExtensions.includes(fileExtension);
1387
1388 if (!isAudioType && !isAudioExtension) {
1389 throw ValidationErrors.unsupportedFileType(
1390 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
1391 );
1392 }
1393
1394 if (file.size > MAX_FILE_SIZE) {
1395 throw ValidationErrors.fileTooLarge("100MB");
1396 }
1397
1398 // Generate unique filename
1399 const transcriptionId = crypto.randomUUID();
1400 const filename = `${transcriptionId}.${fileExtension}`;
1401
1402 // Save file to disk
1403 const uploadDir = "./uploads";
1404 await Bun.write(`${uploadDir}/${filename}`, file);
1405
1406 // Create database record
1407 db.run(
1408 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
1409 [
1410 transcriptionId,
1411 user.id,
1412 classId,
1413 meetingTimeId,
1414 filename,
1415 file.name,
1416 "pending",
1417 ],
1418 );
1419
1420 // Don't auto-start transcription - admin will select recordings
1421 // whisperService.startTranscription(transcriptionId, filename);
1422
1423 return Response.json({
1424 id: transcriptionId,
1425 message: "Upload successful",
1426 });
1427 } catch (error) {
1428 return handleError(error);
1429 }
1430 },
1431 },
1432 "/api/admin/transcriptions": {
1433 GET: async (req) => {
1434 try {
1435 requireAdmin(req);
1436 const transcriptions = getAllTranscriptions();
1437 return Response.json(transcriptions);
1438 } catch (error) {
1439 return handleError(error);
1440 }
1441 },
1442 },
1443 "/api/admin/users": {
1444 GET: async (req) => {
1445 try {
1446 requireAdmin(req);
1447 const users = getAllUsersWithStats();
1448 return Response.json(users);
1449 } catch (error) {
1450 return handleError(error);
1451 }
1452 },
1453 },
1454 "/api/admin/classes": {
1455 GET: async (req) => {
1456 try {
1457 requireAdmin(req);
1458 const classes = getClassesForUser(0, true); // Admin sees all classes
1459 return Response.json({ classes });
1460 } catch (error) {
1461 return handleError(error);
1462 }
1463 },
1464 },
1465 "/api/admin/waitlist": {
1466 GET: async (req) => {
1467 try {
1468 requireAdmin(req);
1469 const waitlist = getAllWaitlistEntries();
1470 return Response.json({ waitlist });
1471 } catch (error) {
1472 return handleError(error);
1473 }
1474 },
1475 },
1476 "/api/admin/waitlist/:id": {
1477 DELETE: async (req) => {
1478 try {
1479 requireAdmin(req);
1480 const id = req.params.id;
1481 deleteWaitlistEntry(id);
1482 return Response.json({ success: true });
1483 } catch (error) {
1484 return handleError(error);
1485 }
1486 },
1487 },
1488 "/api/admin/transcriptions/:id": {
1489 DELETE: async (req) => {
1490 try {
1491 requireAdmin(req);
1492 const transcriptionId = req.params.id;
1493 deleteTranscription(transcriptionId);
1494 return Response.json({ success: true });
1495 } catch (error) {
1496 return handleError(error);
1497 }
1498 },
1499 },
1500 "/api/admin/users/:id": {
1501 DELETE: async (req) => {
1502 try {
1503 requireAdmin(req);
1504 const userId = Number.parseInt(req.params.id, 10);
1505 if (Number.isNaN(userId)) {
1506 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1507 }
1508 await deleteUser(userId);
1509 return Response.json({ success: true });
1510 } catch (error) {
1511 return handleError(error);
1512 }
1513 },
1514 },
1515 "/api/admin/users/:id/role": {
1516 PUT: async (req) => {
1517 try {
1518 requireAdmin(req);
1519 const userId = Number.parseInt(req.params.id, 10);
1520 if (Number.isNaN(userId)) {
1521 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1522 }
1523
1524 const body = await req.json();
1525 const { role } = body as { role: UserRole };
1526
1527 if (!role || (role !== "user" && role !== "admin")) {
1528 return Response.json(
1529 { error: "Invalid role. Must be 'user' or 'admin'" },
1530 { status: 400 },
1531 );
1532 }
1533
1534 updateUserRole(userId, role);
1535 return Response.json({ success: true });
1536 } catch (error) {
1537 return handleError(error);
1538 }
1539 },
1540 },
1541 "/api/admin/users/:id/subscription": {
1542 DELETE: async (req) => {
1543 try {
1544 requireAdmin(req);
1545 const userId = Number.parseInt(req.params.id, 10);
1546 if (Number.isNaN(userId)) {
1547 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1548 }
1549
1550 const body = await req.json();
1551 const { subscriptionId } = body as { subscriptionId: string };
1552
1553 if (!subscriptionId) {
1554 return Response.json(
1555 { error: "Subscription ID required" },
1556 { status: 400 },
1557 );
1558 }
1559
1560 try {
1561 const { polar } = await import("./lib/polar");
1562 await polar.subscriptions.revoke({ id: subscriptionId });
1563 return Response.json({
1564 success: true,
1565 message: "Subscription revoked successfully",
1566 });
1567 } catch (error) {
1568 console.error(
1569 `[Admin] Failed to revoke subscription ${subscriptionId}:`,
1570 error,
1571 );
1572 return Response.json(
1573 {
1574 error:
1575 error instanceof Error
1576 ? error.message
1577 : "Failed to revoke subscription",
1578 },
1579 { status: 500 },
1580 );
1581 }
1582 } catch (error) {
1583 return handleError(error);
1584 }
1585 },
1586 PUT: async (req) => {
1587 try {
1588 requireAdmin(req);
1589 const userId = Number.parseInt(req.params.id, 10);
1590 if (Number.isNaN(userId)) {
1591 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1592 }
1593
1594 // Get user email
1595 const user = db
1596 .query<{ email: string }, [number]>(
1597 "SELECT email FROM users WHERE id = ?",
1598 )
1599 .get(userId);
1600
1601 if (!user) {
1602 return Response.json(
1603 { error: "User not found" },
1604 { status: 404 },
1605 );
1606 }
1607
1608 try {
1609 await syncUserSubscriptionsFromPolar(userId, user.email);
1610 return Response.json({
1611 success: true,
1612 message: "Subscription synced successfully",
1613 });
1614 } catch (error) {
1615 console.error(
1616 `[Admin] Failed to sync subscription for user ${userId}:`,
1617 error,
1618 );
1619 return Response.json(
1620 {
1621 error:
1622 error instanceof Error
1623 ? error.message
1624 : "Failed to sync subscription",
1625 },
1626 { status: 500 },
1627 );
1628 }
1629 } catch (error) {
1630 return handleError(error);
1631 }
1632 },
1633 },
1634 "/api/admin/users/:id/details": {
1635 GET: async (req) => {
1636 try {
1637 requireAdmin(req);
1638 const userId = Number.parseInt(req.params.id, 10);
1639 if (Number.isNaN(userId)) {
1640 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1641 }
1642
1643 const user = db
1644 .query<
1645 {
1646 id: number;
1647 email: string;
1648 name: string | null;
1649 avatar: string;
1650 created_at: number;
1651 role: UserRole;
1652 password_hash: string | null;
1653 last_login: number | null;
1654 },
1655 [number]
1656 >(
1657 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?",
1658 )
1659 .get(userId);
1660
1661 if (!user) {
1662 return Response.json({ error: "User not found" }, { status: 404 });
1663 }
1664
1665 const passkeys = getPasskeysForUser(userId);
1666 const sessions = getSessionsForUser(userId);
1667
1668 // Get transcription count
1669 const transcriptionCount =
1670 db
1671 .query<{ count: number }, [number]>(
1672 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?",
1673 )
1674 .get(userId)?.count ?? 0;
1675
1676 return Response.json({
1677 id: user.id,
1678 email: user.email,
1679 name: user.name,
1680 avatar: user.avatar,
1681 created_at: user.created_at,
1682 role: user.role,
1683 last_login: user.last_login,
1684 hasPassword: !!user.password_hash,
1685 transcriptionCount,
1686 passkeys: passkeys.map((pk) => ({
1687 id: pk.id,
1688 name: pk.name,
1689 created_at: pk.created_at,
1690 last_used_at: pk.last_used_at,
1691 })),
1692 sessions: sessions.map((s) => ({
1693 id: s.id,
1694 ip_address: s.ip_address,
1695 user_agent: s.user_agent,
1696 created_at: s.created_at,
1697 expires_at: s.expires_at,
1698 })),
1699 });
1700 } catch (error) {
1701 return handleError(error);
1702 }
1703 },
1704 },
1705 "/api/admin/users/:id/password": {
1706 PUT: async (req) => {
1707 try {
1708 requireAdmin(req);
1709 const userId = Number.parseInt(req.params.id, 10);
1710 if (Number.isNaN(userId)) {
1711 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1712 }
1713
1714 const body = await req.json();
1715 const { password } = body as { password: string };
1716
1717 if (!password || password.length < 8) {
1718 return Response.json(
1719 { error: "Password must be at least 8 characters" },
1720 { status: 400 },
1721 );
1722 }
1723
1724 await updateUserPassword(userId, password);
1725 return Response.json({ success: true });
1726 } catch (error) {
1727 return handleError(error);
1728 }
1729 },
1730 },
1731 "/api/admin/users/:id/passkeys/:passkeyId": {
1732 DELETE: async (req) => {
1733 try {
1734 requireAdmin(req);
1735 const userId = Number.parseInt(req.params.id, 10);
1736 if (Number.isNaN(userId)) {
1737 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1738 }
1739
1740 const { passkeyId } = req.params;
1741 deletePasskey(passkeyId, userId);
1742 return Response.json({ success: true });
1743 } catch (error) {
1744 return handleError(error);
1745 }
1746 },
1747 },
1748 "/api/admin/users/:id/name": {
1749 PUT: async (req) => {
1750 try {
1751 requireAdmin(req);
1752 const userId = Number.parseInt(req.params.id, 10);
1753 if (Number.isNaN(userId)) {
1754 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1755 }
1756
1757 const body = await req.json();
1758 const { name } = body as { name: string };
1759
1760 if (!name || name.trim().length === 0) {
1761 return Response.json(
1762 { error: "Name cannot be empty" },
1763 { status: 400 },
1764 );
1765 }
1766
1767 updateUserName(userId, name.trim());
1768 return Response.json({ success: true });
1769 } catch (error) {
1770 return handleError(error);
1771 }
1772 },
1773 },
1774 "/api/admin/users/:id/email": {
1775 PUT: async (req) => {
1776 try {
1777 requireAdmin(req);
1778 const userId = Number.parseInt(req.params.id, 10);
1779 if (Number.isNaN(userId)) {
1780 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1781 }
1782
1783 const body = await req.json();
1784 const { email } = body as { email: string };
1785
1786 if (!email || !email.includes("@")) {
1787 return Response.json(
1788 { error: "Invalid email address" },
1789 { status: 400 },
1790 );
1791 }
1792
1793 // Check if email already exists
1794 const existing = db
1795 .query<{ id: number }, [string, number]>(
1796 "SELECT id FROM users WHERE email = ? AND id != ?",
1797 )
1798 .get(email, userId);
1799
1800 if (existing) {
1801 return Response.json(
1802 { error: "Email already in use" },
1803 { status: 400 },
1804 );
1805 }
1806
1807 updateUserEmailAddress(userId, email);
1808 return Response.json({ success: true });
1809 } catch (error) {
1810 return handleError(error);
1811 }
1812 },
1813 },
1814 "/api/admin/users/:id/sessions": {
1815 GET: async (req) => {
1816 try {
1817 requireAdmin(req);
1818 const userId = Number.parseInt(req.params.id, 10);
1819 if (Number.isNaN(userId)) {
1820 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1821 }
1822
1823 const sessions = getSessionsForUser(userId);
1824 return Response.json(sessions);
1825 } catch (error) {
1826 return handleError(error);
1827 }
1828 },
1829 DELETE: async (req) => {
1830 try {
1831 requireAdmin(req);
1832 const userId = Number.parseInt(req.params.id, 10);
1833 if (Number.isNaN(userId)) {
1834 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1835 }
1836
1837 deleteAllUserSessions(userId);
1838 return Response.json({ success: true });
1839 } catch (error) {
1840 return handleError(error);
1841 }
1842 },
1843 },
1844 "/api/admin/users/:id/sessions/:sessionId": {
1845 DELETE: async (req) => {
1846 try {
1847 requireAdmin(req);
1848 const userId = Number.parseInt(req.params.id, 10);
1849 if (Number.isNaN(userId)) {
1850 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1851 }
1852
1853 const { sessionId } = req.params;
1854 const success = deleteSessionById(sessionId, userId);
1855
1856 if (!success) {
1857 return Response.json(
1858 { error: "Session not found" },
1859 { status: 404 },
1860 );
1861 }
1862
1863 return Response.json({ success: true });
1864 } catch (error) {
1865 return handleError(error);
1866 }
1867 },
1868 },
1869 "/api/admin/transcriptions/:id/details": {
1870 GET: async (req) => {
1871 try {
1872 requireAdmin(req);
1873 const transcriptionId = req.params.id;
1874
1875 const transcription = db
1876 .query<
1877 {
1878 id: string;
1879 original_filename: string;
1880 status: string;
1881 created_at: number;
1882 updated_at: number;
1883 error_message: string | null;
1884 user_id: number;
1885 },
1886 [string]
1887 >(
1888 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?",
1889 )
1890 .get(transcriptionId);
1891
1892 if (!transcription) {
1893 return Response.json(
1894 { error: "Transcription not found" },
1895 { status: 404 },
1896 );
1897 }
1898
1899 const user = db
1900 .query<{ email: string; name: string | null }, [number]>(
1901 "SELECT email, name FROM users WHERE id = ?",
1902 )
1903 .get(transcription.user_id);
1904
1905 return Response.json({
1906 id: transcription.id,
1907 original_filename: transcription.original_filename,
1908 status: transcription.status,
1909 created_at: transcription.created_at,
1910 completed_at: transcription.updated_at,
1911 error_message: transcription.error_message,
1912 user_id: transcription.user_id,
1913 user_email: user?.email || "Unknown",
1914 user_name: user?.name || null,
1915 });
1916 } catch (error) {
1917 return handleError(error);
1918 }
1919 },
1920 },
1921 "/api/classes": {
1922 GET: async (req) => {
1923 try {
1924 const user = requireAuth(req);
1925 const classes = getClassesForUser(user.id, user.role === "admin");
1926
1927 // Group by semester/year
1928 const grouped: Record<
1929 string,
1930 Array<{
1931 id: string;
1932 course_code: string;
1933 name: string;
1934 professor: string;
1935 semester: string;
1936 year: number;
1937 archived: boolean;
1938 }>
1939 > = {};
1940
1941 for (const cls of classes) {
1942 const key = `${cls.semester} ${cls.year}`;
1943 if (!grouped[key]) {
1944 grouped[key] = [];
1945 }
1946 grouped[key]?.push({
1947 id: cls.id,
1948 course_code: cls.course_code,
1949 name: cls.name,
1950 professor: cls.professor,
1951 semester: cls.semester,
1952 year: cls.year,
1953 archived: cls.archived,
1954 });
1955 }
1956
1957 return Response.json({ classes: grouped });
1958 } catch (error) {
1959 return handleError(error);
1960 }
1961 },
1962 POST: async (req) => {
1963 try {
1964 requireAdmin(req);
1965 const body = await req.json();
1966 const {
1967 course_code,
1968 name,
1969 professor,
1970 semester,
1971 year,
1972 meeting_times,
1973 } = body;
1974
1975 if (!course_code || !name || !professor || !semester || !year) {
1976 return Response.json(
1977 { error: "Missing required fields" },
1978 { status: 400 },
1979 );
1980 }
1981
1982 const newClass = createClass({
1983 course_code,
1984 name,
1985 professor,
1986 semester,
1987 year,
1988 meeting_times,
1989 });
1990
1991 return Response.json(newClass);
1992 } catch (error) {
1993 return handleError(error);
1994 }
1995 },
1996 },
1997 "/api/classes/search": {
1998 GET: async (req) => {
1999 try {
2000 const user = requireAuth(req);
2001 const url = new URL(req.url);
2002 const query = url.searchParams.get("q");
2003
2004 if (!query) {
2005 return Response.json({ classes: [] });
2006 }
2007
2008 const classes = searchClassesByCourseCode(query);
2009
2010 // Get user's enrolled classes to mark them
2011 const enrolledClassIds = db
2012 .query<{ class_id: string }, [number]>(
2013 "SELECT class_id FROM class_members WHERE user_id = ?",
2014 )
2015 .all(user.id)
2016 .map((row) => row.class_id);
2017
2018 // Add is_enrolled flag to each class
2019 const classesWithEnrollment = classes.map((cls) => ({
2020 ...cls,
2021 is_enrolled: enrolledClassIds.includes(cls.id),
2022 }));
2023
2024 return Response.json({ classes: classesWithEnrollment });
2025 } catch (error) {
2026 return handleError(error);
2027 }
2028 },
2029 },
2030 "/api/classes/join": {
2031 POST: async (req) => {
2032 try {
2033 const user = requireAuth(req);
2034 const body = await req.json();
2035 const classId = body.class_id;
2036
2037 if (!classId || typeof classId !== "string") {
2038 return Response.json(
2039 { error: "Class ID required" },
2040 { status: 400 },
2041 );
2042 }
2043
2044 const result = joinClass(classId, user.id);
2045
2046 if (!result.success) {
2047 return Response.json({ error: result.error }, { status: 400 });
2048 }
2049
2050 return Response.json({ success: true });
2051 } catch (error) {
2052 return handleError(error);
2053 }
2054 },
2055 },
2056 "/api/classes/waitlist": {
2057 POST: async (req) => {
2058 try {
2059 const user = requireAuth(req);
2060 const body = await req.json();
2061
2062 const {
2063 courseCode,
2064 courseName,
2065 professor,
2066 semester,
2067 year,
2068 additionalInfo,
2069 meetingTimes,
2070 } = body;
2071
2072 if (!courseCode || !courseName || !professor || !semester || !year) {
2073 return Response.json(
2074 { error: "Missing required fields" },
2075 { status: 400 },
2076 );
2077 }
2078
2079 const id = addToWaitlist(
2080 user.id,
2081 courseCode,
2082 courseName,
2083 professor,
2084 semester,
2085 Number.parseInt(year, 10),
2086 additionalInfo || null,
2087 meetingTimes || null,
2088 );
2089
2090 return Response.json({ success: true, id });
2091 } catch (error) {
2092 return handleError(error);
2093 }
2094 },
2095 },
2096 "/api/classes/:id": {
2097 GET: async (req) => {
2098 try {
2099 const user = requireAuth(req);
2100 const classId = req.params.id;
2101
2102 const classInfo = getClassById(classId);
2103 if (!classInfo) {
2104 return Response.json({ error: "Class not found" }, { status: 404 });
2105 }
2106
2107 // Check enrollment or admin
2108 const isEnrolled = isUserEnrolledInClass(user.id, classId);
2109 if (!isEnrolled && user.role !== "admin") {
2110 return Response.json(
2111 { error: "Not enrolled in this class" },
2112 { status: 403 },
2113 );
2114 }
2115
2116 const meetingTimes = getMeetingTimesForClass(classId);
2117 const transcriptions = getTranscriptionsForClass(classId);
2118
2119 return Response.json({
2120 class: classInfo,
2121 meetingTimes,
2122 transcriptions,
2123 });
2124 } catch (error) {
2125 return handleError(error);
2126 }
2127 },
2128 DELETE: async (req) => {
2129 try {
2130 requireAdmin(req);
2131 const classId = req.params.id;
2132
2133 deleteClass(classId);
2134 return Response.json({ success: true });
2135 } catch (error) {
2136 return handleError(error);
2137 }
2138 },
2139 },
2140 "/api/classes/:id/archive": {
2141 PUT: async (req) => {
2142 try {
2143 requireAdmin(req);
2144 const classId = req.params.id;
2145 const body = await req.json();
2146 const { archived } = body;
2147
2148 if (typeof archived !== "boolean") {
2149 return Response.json(
2150 { error: "archived must be a boolean" },
2151 { status: 400 },
2152 );
2153 }
2154
2155 toggleClassArchive(classId, archived);
2156 return Response.json({ success: true });
2157 } catch (error) {
2158 return handleError(error);
2159 }
2160 },
2161 },
2162 "/api/classes/:id/members": {
2163 GET: async (req) => {
2164 try {
2165 requireAdmin(req);
2166 const classId = req.params.id;
2167
2168 const members = getClassMembers(classId);
2169 return Response.json({ members });
2170 } catch (error) {
2171 return handleError(error);
2172 }
2173 },
2174 POST: async (req) => {
2175 try {
2176 requireAdmin(req);
2177 const classId = req.params.id;
2178 const body = await req.json();
2179 const { email } = body;
2180
2181 if (!email) {
2182 return Response.json({ error: "Email required" }, { status: 400 });
2183 }
2184
2185 const user = getUserByEmail(email);
2186 if (!user) {
2187 return Response.json({ error: "User not found" }, { status: 404 });
2188 }
2189
2190 enrollUserInClass(user.id, classId);
2191 return Response.json({ success: true });
2192 } catch (error) {
2193 return handleError(error);
2194 }
2195 },
2196 },
2197 "/api/classes/:id/members/:userId": {
2198 DELETE: async (req) => {
2199 try {
2200 requireAdmin(req);
2201 const classId = req.params.id;
2202 const userId = Number.parseInt(req.params.userId, 10);
2203
2204 if (Number.isNaN(userId)) {
2205 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2206 }
2207
2208 removeUserFromClass(userId, classId);
2209 return Response.json({ success: true });
2210 } catch (error) {
2211 return handleError(error);
2212 }
2213 },
2214 },
2215 "/api/classes/:id/meetings": {
2216 GET: async (req) => {
2217 try {
2218 const user = requireAuth(req);
2219 const classId = req.params.id;
2220
2221 // Check enrollment or admin
2222 const isEnrolled = isUserEnrolledInClass(user.id, classId);
2223 if (!isEnrolled && user.role !== "admin") {
2224 return Response.json(
2225 { error: "Not enrolled in this class" },
2226 { status: 403 },
2227 );
2228 }
2229
2230 const meetingTimes = getMeetingTimesForClass(classId);
2231 return Response.json({ meetings: meetingTimes });
2232 } catch (error) {
2233 return handleError(error);
2234 }
2235 },
2236 POST: async (req) => {
2237 try {
2238 requireAdmin(req);
2239 const classId = req.params.id;
2240 const body = await req.json();
2241 const { label } = body;
2242
2243 if (!label) {
2244 return Response.json({ error: "Label required" }, { status: 400 });
2245 }
2246
2247 const meetingTime = createMeetingTime(classId, label);
2248 return Response.json(meetingTime);
2249 } catch (error) {
2250 return handleError(error);
2251 }
2252 },
2253 },
2254 "/api/meetings/:id": {
2255 PUT: async (req) => {
2256 try {
2257 requireAdmin(req);
2258 const meetingId = req.params.id;
2259 const body = await req.json();
2260 const { label } = body;
2261
2262 if (!label) {
2263 return Response.json({ error: "Label required" }, { status: 400 });
2264 }
2265
2266 updateMeetingTime(meetingId, label);
2267 return Response.json({ success: true });
2268 } catch (error) {
2269 return handleError(error);
2270 }
2271 },
2272 DELETE: async (req) => {
2273 try {
2274 requireAdmin(req);
2275 const meetingId = req.params.id;
2276
2277 deleteMeetingTime(meetingId);
2278 return Response.json({ success: true });
2279 } catch (error) {
2280 return handleError(error);
2281 }
2282 },
2283 },
2284 "/api/transcripts/:id/select": {
2285 PUT: async (req) => {
2286 try {
2287 requireAdmin(req);
2288 const transcriptId = req.params.id;
2289
2290 // Update status to 'selected' and start transcription
2291 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
2292 "selected",
2293 transcriptId,
2294 ]);
2295
2296 // Get filename to start transcription
2297 const transcription = db
2298 .query<{ filename: string }, [string]>(
2299 "SELECT filename FROM transcriptions WHERE id = ?",
2300 )
2301 .get(transcriptId);
2302
2303 if (transcription) {
2304 whisperService.startTranscription(
2305 transcriptId,
2306 transcription.filename,
2307 );
2308 }
2309
2310 return Response.json({ success: true });
2311 } catch (error) {
2312 return handleError(error);
2313 }
2314 },
2315 },
2316 },
2317 development: {
2318 hmr: true,
2319 console: true,
2320 },
2321});
2322console.log(`馃 Thistle running at http://localhost:${server.port}`);