···253 CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id);
254 `,
255 },
0000000000000000000000000256];
257258function getCurrentVersion(): number {
···253 CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id);
254 `,
255 },
256+ {
257+ version: 3,
258+ name: "Add voting system for collaborative recording selection",
259+ sql: `
260+ -- Add vote count to transcriptions
261+ ALTER TABLE transcriptions ADD COLUMN vote_count INTEGER NOT NULL DEFAULT 0;
262+263+ -- Add auto-submitted flag to track if transcription was auto-selected
264+ ALTER TABLE transcriptions ADD COLUMN auto_submitted BOOLEAN DEFAULT 0;
265+266+ -- Create votes table to track who voted for which recording
267+ CREATE TABLE IF NOT EXISTS recording_votes (
268+ id TEXT PRIMARY KEY,
269+ transcription_id TEXT NOT NULL,
270+ user_id INTEGER NOT NULL,
271+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
272+ FOREIGN KEY (transcription_id) REFERENCES transcriptions(id) ON DELETE CASCADE,
273+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
274+ UNIQUE(transcription_id, user_id)
275+ );
276+277+ CREATE INDEX IF NOT EXISTS idx_recording_votes_transcription_id ON recording_votes(transcription_id);
278+ CREATE INDEX IF NOT EXISTS idx_recording_votes_user_id ON recording_votes(user_id);
279+ `,
280+ },
281];
282283function getCurrentVersion(): number {
+248-75
src/index.ts
···92 WhisperServiceManager,
93} from "./lib/transcription";
94import {
95- extractAudioCreationDate,
96 findMatchingMeetingTime,
97 getDayName,
98} from "./lib/audio-metadata";
000000000099import {
100 validateClassId,
101 validateCourseCode,
···21132114 let creationDate: Date | null = null;
21152116- // Try client-provided timestamp first (most accurate - from original file)
2117 if (fileTimestampStr) {
2118 const timestamp = Number.parseInt(fileTimestampStr, 10);
2119 if (!Number.isNaN(timestamp)) {
2120 creationDate = new Date(timestamp);
2121 console.log(
2122- `[Upload] Using client-provided file timestamp: ${creationDate.toISOString()}`,
2123 );
2124 }
2125 }
21262127- // Fallback: extract from audio file metadata
2128- if (!creationDate) {
2129- // Save file temporarily
2130- const tempId = crypto.randomUUID();
2131- const fileExtension = file.name.split(".").pop()?.toLowerCase();
2132- const tempFilename = `temp-${tempId}.${fileExtension}`;
2133- const tempPath = `./uploads/${tempFilename}`;
2134-2135- await Bun.write(tempPath, file);
2136-2137- try {
2138- creationDate = await extractAudioCreationDate(tempPath);
2139- } finally {
2140- // Clean up temp file
2141- try {
2142- await Bun.$`rm ${tempPath}`.quiet();
2143- } catch {
2144- // Ignore cleanup errors
2145- }
2146- }
2147- }
2148-2149 if (!creationDate) {
2150 return Response.json({
2151 detected: false,
2152 meeting_time_id: null,
2153- message: "Could not extract creation date from audio file",
2154 });
2155 }
2156···2194 }
2195 },
2196 },
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002197 "/api/transcriptions": {
2198 GET: async (req) => {
2199 try {
···2336 const formData = await req.formData();
2337 const file = formData.get("audio") as File;
2338 const classId = formData.get("class_id") as string | null;
2339- const meetingTimeId = formData.get("meeting_time_id") as
2340- | string
2341- | null;
2342 const sectionId = formData.get("section_id") as string | null;
23432344 if (!file) throw ValidationErrors.missingField("audio");
···2406 const uploadDir = "./uploads";
2407 await Bun.write(`${uploadDir}/${filename}`, file);
24082409- // Auto-detect meeting time from audio metadata if class provided and no meeting_time_id
2410- let finalMeetingTimeId = meetingTimeId;
2411- if (classId && !meetingTimeId) {
2412- try {
2413- // Extract creation date from audio file
2414- const creationDate = await extractAudioCreationDate(
2415- `${uploadDir}/${filename}`,
2416- );
2417-2418- if (creationDate) {
2419- // Get meeting times for this class
2420- const meetingTimes = getMeetingTimesForClass(classId);
2421-2422- if (meetingTimes.length > 0) {
2423- // Find matching meeting time based on day of week
2424- const matchedId = findMatchingMeetingTime(
2425- creationDate,
2426- meetingTimes,
2427- );
2428-2429- if (matchedId) {
2430- finalMeetingTimeId = matchedId;
2431- const dayName = getDayName(creationDate);
2432- console.log(
2433- `[Upload] Auto-detected meeting time for ${dayName} (${creationDate.toISOString()}) -> ${matchedId}`,
2434- );
2435- } else {
2436- const dayName = getDayName(creationDate);
2437- console.log(
2438- `[Upload] No meeting time matches ${dayName}, leaving unassigned`,
2439- );
2440- }
2441- }
2442- }
2443- } catch (error) {
2444- // Non-fatal: just log and continue without auto-detection
2445- console.warn(
2446- "[Upload] Failed to auto-detect meeting time:",
2447- error instanceof Error ? error.message : "Unknown error",
2448- );
2449- }
2450- }
2451-2452- // Create database record
2453 db.run(
2454 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
2455 [
2456 transcriptionId,
2457 user.id,
2458 classId,
2459- finalMeetingTimeId,
2460 sectionId,
2461 filename,
2462 file.name,
···2470 return Response.json(
2471 {
2472 id: transcriptionId,
2473- meeting_time_id: finalMeetingTimeId,
2474 message: "Upload successful",
2475 },
2476 { status: 201 },
···92 WhisperServiceManager,
93} from "./lib/transcription";
94import {
095 findMatchingMeetingTime,
96 getDayName,
97} from "./lib/audio-metadata";
98+import {
99+ checkAutoSubmit,
100+ deletePendingRecording,
101+ getEnrolledUserCount,
102+ getPendingRecordings,
103+ getUserVoteForMeeting,
104+ markAsAutoSubmitted,
105+ removeVote,
106+ voteForRecording,
107+} from "./lib/voting";
108import {
109 validateClassId,
110 validateCourseCode,
···21222123 let creationDate: Date | null = null;
21242125+ // Use client-provided timestamp (from File.lastModified)
2126 if (fileTimestampStr) {
2127 const timestamp = Number.parseInt(fileTimestampStr, 10);
2128 if (!Number.isNaN(timestamp)) {
2129 creationDate = new Date(timestamp);
2130 console.log(
2131+ `[Upload] Using file timestamp: ${creationDate.toISOString()}`,
2132 );
2133 }
2134 }
213500000000000000000000002136 if (!creationDate) {
2137 return Response.json({
2138 detected: false,
2139 meeting_time_id: null,
2140+ message: "Could not extract creation date from file",
2141 });
2142 }
2143···2181 }
2182 },
2183 },
2184+ "/api/transcriptions/:id/meeting-time": {
2185+ PATCH: async (req) => {
2186+ try {
2187+ const user = requireAuth(req);
2188+ const transcriptionId = req.params.id;
2189+2190+ const body = await req.json();
2191+ const meetingTimeId = body.meeting_time_id;
2192+2193+ if (!meetingTimeId) {
2194+ return Response.json(
2195+ { error: "meeting_time_id required" },
2196+ { status: 400 },
2197+ );
2198+ }
2199+2200+ // Verify transcription ownership
2201+ const transcription = db
2202+ .query<
2203+ { id: string; user_id: number; class_id: string | null },
2204+ [string]
2205+ >("SELECT id, user_id, class_id FROM transcriptions WHERE id = ?")
2206+ .get(transcriptionId);
2207+2208+ if (!transcription) {
2209+ return Response.json(
2210+ { error: "Transcription not found" },
2211+ { status: 404 },
2212+ );
2213+ }
2214+2215+ if (transcription.user_id !== user.id && user.role !== "admin") {
2216+ return Response.json({ error: "Forbidden" }, { status: 403 });
2217+ }
2218+2219+ // Verify meeting time belongs to the class
2220+ if (transcription.class_id) {
2221+ const meetingTime = db
2222+ .query<{ id: string }, [string, string]>(
2223+ "SELECT id FROM meeting_times WHERE id = ? AND class_id = ?",
2224+ )
2225+ .get(meetingTimeId, transcription.class_id);
2226+2227+ if (!meetingTime) {
2228+ return Response.json(
2229+ {
2230+ error:
2231+ "Meeting time does not belong to the class for this transcription",
2232+ },
2233+ { status: 400 },
2234+ );
2235+ }
2236+ }
2237+2238+ // Update meeting time
2239+ db.run(
2240+ "UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?",
2241+ [meetingTimeId, transcriptionId],
2242+ );
2243+2244+ return Response.json({
2245+ success: true,
2246+ message: "Meeting time updated successfully",
2247+ });
2248+ } catch (error) {
2249+ return handleError(error);
2250+ }
2251+ },
2252+ },
2253+ "/api/classes/:classId/meetings/:meetingTimeId/recordings": {
2254+ GET: async (req) => {
2255+ try {
2256+ const user = requireAuth(req);
2257+ const classId = req.params.classId;
2258+ const meetingTimeId = req.params.meetingTimeId;
2259+2260+ // Verify user is enrolled in the class
2261+ const enrolled = isUserEnrolledInClass(user.id, classId);
2262+ if (!enrolled && user.role !== "admin") {
2263+ return Response.json(
2264+ { error: "Not enrolled in this class" },
2265+ { status: 403 },
2266+ );
2267+ }
2268+2269+ // Get user's section for filtering (admins see all)
2270+ const userSection =
2271+ user.role === "admin" ? null : getUserSection(user.id, classId);
2272+2273+ const recordings = getPendingRecordings(
2274+ classId,
2275+ meetingTimeId,
2276+ userSection,
2277+ );
2278+ const totalUsers = getEnrolledUserCount(classId);
2279+ const userVote = getUserVoteForMeeting(
2280+ user.id,
2281+ classId,
2282+ meetingTimeId,
2283+ );
2284+2285+ // Check if any recording should be auto-submitted
2286+ const winningId = checkAutoSubmit(
2287+ classId,
2288+ meetingTimeId,
2289+ userSection,
2290+ );
2291+2292+ return Response.json({
2293+ recordings,
2294+ total_users: totalUsers,
2295+ user_vote: userVote,
2296+ vote_threshold: Math.ceil(totalUsers * 0.4),
2297+ winning_recording_id: winningId,
2298+ });
2299+ } catch (error) {
2300+ return handleError(error);
2301+ }
2302+ },
2303+ },
2304+ "/api/recordings/:id/vote": {
2305+ POST: async (req) => {
2306+ try {
2307+ const user = requireAuth(req);
2308+ const recordingId = req.params.id;
2309+2310+ // Verify user is enrolled in the recording's class
2311+ const recording = db
2312+ .query<
2313+ { class_id: string; meeting_time_id: string; status: string },
2314+ [string]
2315+ >(
2316+ "SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?",
2317+ )
2318+ .get(recordingId);
2319+2320+ if (!recording) {
2321+ return Response.json(
2322+ { error: "Recording not found" },
2323+ { status: 404 },
2324+ );
2325+ }
2326+2327+ if (recording.status !== "pending") {
2328+ return Response.json(
2329+ { error: "Can only vote on pending recordings" },
2330+ { status: 400 },
2331+ );
2332+ }
2333+2334+ const enrolled = isUserEnrolledInClass(user.id, recording.class_id);
2335+ if (!enrolled && user.role !== "admin") {
2336+ return Response.json(
2337+ { error: "Not enrolled in this class" },
2338+ { status: 403 },
2339+ );
2340+ }
2341+2342+ // Remove existing vote for this meeting time
2343+ const existingVote = getUserVoteForMeeting(
2344+ user.id,
2345+ recording.class_id,
2346+ recording.meeting_time_id,
2347+ );
2348+ if (existingVote) {
2349+ removeVote(existingVote, user.id);
2350+ }
2351+2352+ // Add new vote
2353+ const success = voteForRecording(recordingId, user.id);
2354+2355+ // Get user's section for auto-submit check
2356+ const userSection =
2357+ user.role === "admin"
2358+ ? null
2359+ : getUserSection(user.id, recording.class_id);
2360+2361+ // Check if auto-submit threshold reached
2362+ const winningId = checkAutoSubmit(
2363+ recording.class_id,
2364+ recording.meeting_time_id,
2365+ userSection,
2366+ );
2367+ if (winningId) {
2368+ markAsAutoSubmitted(winningId);
2369+ // Start transcription
2370+ const winningRecording = db
2371+ .query<{ filename: string }, [string]>(
2372+ "SELECT filename FROM transcriptions WHERE id = ?",
2373+ )
2374+ .get(winningId);
2375+ if (winningRecording) {
2376+ whisperService.startTranscription(
2377+ winningId,
2378+ winningRecording.filename,
2379+ );
2380+ }
2381+ }
2382+2383+ return Response.json({
2384+ success,
2385+ winning_recording_id: winningId,
2386+ });
2387+ } catch (error) {
2388+ return handleError(error);
2389+ }
2390+ },
2391+ },
2392+ "/api/recordings/:id": {
2393+ DELETE: async (req) => {
2394+ try {
2395+ const user = requireAuth(req);
2396+ const recordingId = req.params.id;
2397+2398+ const success = deletePendingRecording(
2399+ recordingId,
2400+ user.id,
2401+ user.role === "admin",
2402+ );
2403+2404+ if (!success) {
2405+ return Response.json(
2406+ { error: "Cannot delete this recording" },
2407+ { status: 403 },
2408+ );
2409+ }
2410+2411+ return new Response(null, { status: 204 });
2412+ } catch (error) {
2413+ return handleError(error);
2414+ }
2415+ },
2416+ },
2417 "/api/transcriptions": {
2418 GET: async (req) => {
2419 try {
···2556 const formData = await req.formData();
2557 const file = formData.get("audio") as File;
2558 const classId = formData.get("class_id") as string | null;
0002559 const sectionId = formData.get("section_id") as string | null;
25602561 if (!file) throw ValidationErrors.missingField("audio");
···2623 const uploadDir = "./uploads";
2624 await Bun.write(`${uploadDir}/${filename}`, file);
26252626+ // Create database record (without meeting_time_id - will be set later via PATCH)
00000000000000000000000000000000000000000002627 db.run(
2628 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
2629 [
2630 transcriptionId,
2631 user.id,
2632 classId,
2633+ null, // meeting_time_id will be set via PATCH endpoint
2634 sectionId,
2635 filename,
2636 file.name,
···2644 return Response.json(
2645 {
2646 id: transcriptionId,
02647 message: "Upload successful",
2648 },
2649 { status: 201 },