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