···253253 CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id);
254254 `,
255255 },
256256+ {
257257+ version: 3,
258258+ name: "Add voting system for collaborative recording selection",
259259+ sql: `
260260+ -- Add vote count to transcriptions
261261+ ALTER TABLE transcriptions ADD COLUMN vote_count INTEGER NOT NULL DEFAULT 0;
262262+263263+ -- Add auto-submitted flag to track if transcription was auto-selected
264264+ ALTER TABLE transcriptions ADD COLUMN auto_submitted BOOLEAN DEFAULT 0;
265265+266266+ -- Create votes table to track who voted for which recording
267267+ CREATE TABLE IF NOT EXISTS recording_votes (
268268+ id TEXT PRIMARY KEY,
269269+ transcription_id TEXT NOT NULL,
270270+ user_id INTEGER NOT NULL,
271271+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
272272+ FOREIGN KEY (transcription_id) REFERENCES transcriptions(id) ON DELETE CASCADE,
273273+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
274274+ UNIQUE(transcription_id, user_id)
275275+ );
276276+277277+ CREATE INDEX IF NOT EXISTS idx_recording_votes_transcription_id ON recording_votes(transcription_id);
278278+ CREATE INDEX IF NOT EXISTS idx_recording_votes_user_id ON recording_votes(user_id);
279279+ `,
280280+ },
256281];
257282258283function getCurrentVersion(): number {
+248-75
src/index.ts
···9292 WhisperServiceManager,
9393} from "./lib/transcription";
9494import {
9595- extractAudioCreationDate,
9695 findMatchingMeetingTime,
9796 getDayName,
9897} from "./lib/audio-metadata";
9898+import {
9999+ checkAutoSubmit,
100100+ deletePendingRecording,
101101+ getEnrolledUserCount,
102102+ getPendingRecordings,
103103+ getUserVoteForMeeting,
104104+ markAsAutoSubmitted,
105105+ removeVote,
106106+ voteForRecording,
107107+} from "./lib/voting";
99108import {
100109 validateClassId,
101110 validateCourseCode,
···2113212221142123 let creationDate: Date | null = null;
2115212421162116- // Try client-provided timestamp first (most accurate - from original file)
21252125+ // Use client-provided timestamp (from File.lastModified)
21172126 if (fileTimestampStr) {
21182127 const timestamp = Number.parseInt(fileTimestampStr, 10);
21192128 if (!Number.isNaN(timestamp)) {
21202129 creationDate = new Date(timestamp);
21212130 console.log(
21222122- `[Upload] Using client-provided file timestamp: ${creationDate.toISOString()}`,
21312131+ `[Upload] Using file timestamp: ${creationDate.toISOString()}`,
21232132 );
21242133 }
21252134 }
2126213521272127- // Fallback: extract from audio file metadata
21282128- if (!creationDate) {
21292129- // Save file temporarily
21302130- const tempId = crypto.randomUUID();
21312131- const fileExtension = file.name.split(".").pop()?.toLowerCase();
21322132- const tempFilename = `temp-${tempId}.${fileExtension}`;
21332133- const tempPath = `./uploads/${tempFilename}`;
21342134-21352135- await Bun.write(tempPath, file);
21362136-21372137- try {
21382138- creationDate = await extractAudioCreationDate(tempPath);
21392139- } finally {
21402140- // Clean up temp file
21412141- try {
21422142- await Bun.$`rm ${tempPath}`.quiet();
21432143- } catch {
21442144- // Ignore cleanup errors
21452145- }
21462146- }
21472147- }
21482148-21492136 if (!creationDate) {
21502137 return Response.json({
21512138 detected: false,
21522139 meeting_time_id: null,
21532153- message: "Could not extract creation date from audio file",
21402140+ message: "Could not extract creation date from file",
21542141 });
21552142 }
21562143···21942181 }
21952182 },
21962183 },
21842184+ "/api/transcriptions/:id/meeting-time": {
21852185+ PATCH: async (req) => {
21862186+ try {
21872187+ const user = requireAuth(req);
21882188+ const transcriptionId = req.params.id;
21892189+21902190+ const body = await req.json();
21912191+ const meetingTimeId = body.meeting_time_id;
21922192+21932193+ if (!meetingTimeId) {
21942194+ return Response.json(
21952195+ { error: "meeting_time_id required" },
21962196+ { status: 400 },
21972197+ );
21982198+ }
21992199+22002200+ // Verify transcription ownership
22012201+ const transcription = db
22022202+ .query<
22032203+ { id: string; user_id: number; class_id: string | null },
22042204+ [string]
22052205+ >("SELECT id, user_id, class_id FROM transcriptions WHERE id = ?")
22062206+ .get(transcriptionId);
22072207+22082208+ if (!transcription) {
22092209+ return Response.json(
22102210+ { error: "Transcription not found" },
22112211+ { status: 404 },
22122212+ );
22132213+ }
22142214+22152215+ if (transcription.user_id !== user.id && user.role !== "admin") {
22162216+ return Response.json({ error: "Forbidden" }, { status: 403 });
22172217+ }
22182218+22192219+ // Verify meeting time belongs to the class
22202220+ if (transcription.class_id) {
22212221+ const meetingTime = db
22222222+ .query<{ id: string }, [string, string]>(
22232223+ "SELECT id FROM meeting_times WHERE id = ? AND class_id = ?",
22242224+ )
22252225+ .get(meetingTimeId, transcription.class_id);
22262226+22272227+ if (!meetingTime) {
22282228+ return Response.json(
22292229+ {
22302230+ error:
22312231+ "Meeting time does not belong to the class for this transcription",
22322232+ },
22332233+ { status: 400 },
22342234+ );
22352235+ }
22362236+ }
22372237+22382238+ // Update meeting time
22392239+ db.run(
22402240+ "UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?",
22412241+ [meetingTimeId, transcriptionId],
22422242+ );
22432243+22442244+ return Response.json({
22452245+ success: true,
22462246+ message: "Meeting time updated successfully",
22472247+ });
22482248+ } catch (error) {
22492249+ return handleError(error);
22502250+ }
22512251+ },
22522252+ },
22532253+ "/api/classes/:classId/meetings/:meetingTimeId/recordings": {
22542254+ GET: async (req) => {
22552255+ try {
22562256+ const user = requireAuth(req);
22572257+ const classId = req.params.classId;
22582258+ const meetingTimeId = req.params.meetingTimeId;
22592259+22602260+ // Verify user is enrolled in the class
22612261+ const enrolled = isUserEnrolledInClass(user.id, classId);
22622262+ if (!enrolled && user.role !== "admin") {
22632263+ return Response.json(
22642264+ { error: "Not enrolled in this class" },
22652265+ { status: 403 },
22662266+ );
22672267+ }
22682268+22692269+ // Get user's section for filtering (admins see all)
22702270+ const userSection =
22712271+ user.role === "admin" ? null : getUserSection(user.id, classId);
22722272+22732273+ const recordings = getPendingRecordings(
22742274+ classId,
22752275+ meetingTimeId,
22762276+ userSection,
22772277+ );
22782278+ const totalUsers = getEnrolledUserCount(classId);
22792279+ const userVote = getUserVoteForMeeting(
22802280+ user.id,
22812281+ classId,
22822282+ meetingTimeId,
22832283+ );
22842284+22852285+ // Check if any recording should be auto-submitted
22862286+ const winningId = checkAutoSubmit(
22872287+ classId,
22882288+ meetingTimeId,
22892289+ userSection,
22902290+ );
22912291+22922292+ return Response.json({
22932293+ recordings,
22942294+ total_users: totalUsers,
22952295+ user_vote: userVote,
22962296+ vote_threshold: Math.ceil(totalUsers * 0.4),
22972297+ winning_recording_id: winningId,
22982298+ });
22992299+ } catch (error) {
23002300+ return handleError(error);
23012301+ }
23022302+ },
23032303+ },
23042304+ "/api/recordings/:id/vote": {
23052305+ POST: async (req) => {
23062306+ try {
23072307+ const user = requireAuth(req);
23082308+ const recordingId = req.params.id;
23092309+23102310+ // Verify user is enrolled in the recording's class
23112311+ const recording = db
23122312+ .query<
23132313+ { class_id: string; meeting_time_id: string; status: string },
23142314+ [string]
23152315+ >(
23162316+ "SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?",
23172317+ )
23182318+ .get(recordingId);
23192319+23202320+ if (!recording) {
23212321+ return Response.json(
23222322+ { error: "Recording not found" },
23232323+ { status: 404 },
23242324+ );
23252325+ }
23262326+23272327+ if (recording.status !== "pending") {
23282328+ return Response.json(
23292329+ { error: "Can only vote on pending recordings" },
23302330+ { status: 400 },
23312331+ );
23322332+ }
23332333+23342334+ const enrolled = isUserEnrolledInClass(user.id, recording.class_id);
23352335+ if (!enrolled && user.role !== "admin") {
23362336+ return Response.json(
23372337+ { error: "Not enrolled in this class" },
23382338+ { status: 403 },
23392339+ );
23402340+ }
23412341+23422342+ // Remove existing vote for this meeting time
23432343+ const existingVote = getUserVoteForMeeting(
23442344+ user.id,
23452345+ recording.class_id,
23462346+ recording.meeting_time_id,
23472347+ );
23482348+ if (existingVote) {
23492349+ removeVote(existingVote, user.id);
23502350+ }
23512351+23522352+ // Add new vote
23532353+ const success = voteForRecording(recordingId, user.id);
23542354+23552355+ // Get user's section for auto-submit check
23562356+ const userSection =
23572357+ user.role === "admin"
23582358+ ? null
23592359+ : getUserSection(user.id, recording.class_id);
23602360+23612361+ // Check if auto-submit threshold reached
23622362+ const winningId = checkAutoSubmit(
23632363+ recording.class_id,
23642364+ recording.meeting_time_id,
23652365+ userSection,
23662366+ );
23672367+ if (winningId) {
23682368+ markAsAutoSubmitted(winningId);
23692369+ // Start transcription
23702370+ const winningRecording = db
23712371+ .query<{ filename: string }, [string]>(
23722372+ "SELECT filename FROM transcriptions WHERE id = ?",
23732373+ )
23742374+ .get(winningId);
23752375+ if (winningRecording) {
23762376+ whisperService.startTranscription(
23772377+ winningId,
23782378+ winningRecording.filename,
23792379+ );
23802380+ }
23812381+ }
23822382+23832383+ return Response.json({
23842384+ success,
23852385+ winning_recording_id: winningId,
23862386+ });
23872387+ } catch (error) {
23882388+ return handleError(error);
23892389+ }
23902390+ },
23912391+ },
23922392+ "/api/recordings/:id": {
23932393+ DELETE: async (req) => {
23942394+ try {
23952395+ const user = requireAuth(req);
23962396+ const recordingId = req.params.id;
23972397+23982398+ const success = deletePendingRecording(
23992399+ recordingId,
24002400+ user.id,
24012401+ user.role === "admin",
24022402+ );
24032403+24042404+ if (!success) {
24052405+ return Response.json(
24062406+ { error: "Cannot delete this recording" },
24072407+ { status: 403 },
24082408+ );
24092409+ }
24102410+24112411+ return new Response(null, { status: 204 });
24122412+ } catch (error) {
24132413+ return handleError(error);
24142414+ }
24152415+ },
24162416+ },
21972417 "/api/transcriptions": {
21982418 GET: async (req) => {
21992419 try {
···23362556 const formData = await req.formData();
23372557 const file = formData.get("audio") as File;
23382558 const classId = formData.get("class_id") as string | null;
23392339- const meetingTimeId = formData.get("meeting_time_id") as
23402340- | string
23412341- | null;
23422559 const sectionId = formData.get("section_id") as string | null;
2343256023442561 if (!file) throw ValidationErrors.missingField("audio");
···24062623 const uploadDir = "./uploads";
24072624 await Bun.write(`${uploadDir}/${filename}`, file);
2408262524092409- // Auto-detect meeting time from audio metadata if class provided and no meeting_time_id
24102410- let finalMeetingTimeId = meetingTimeId;
24112411- if (classId && !meetingTimeId) {
24122412- try {
24132413- // Extract creation date from audio file
24142414- const creationDate = await extractAudioCreationDate(
24152415- `${uploadDir}/${filename}`,
24162416- );
24172417-24182418- if (creationDate) {
24192419- // Get meeting times for this class
24202420- const meetingTimes = getMeetingTimesForClass(classId);
24212421-24222422- if (meetingTimes.length > 0) {
24232423- // Find matching meeting time based on day of week
24242424- const matchedId = findMatchingMeetingTime(
24252425- creationDate,
24262426- meetingTimes,
24272427- );
24282428-24292429- if (matchedId) {
24302430- finalMeetingTimeId = matchedId;
24312431- const dayName = getDayName(creationDate);
24322432- console.log(
24332433- `[Upload] Auto-detected meeting time for ${dayName} (${creationDate.toISOString()}) -> ${matchedId}`,
24342434- );
24352435- } else {
24362436- const dayName = getDayName(creationDate);
24372437- console.log(
24382438- `[Upload] No meeting time matches ${dayName}, leaving unassigned`,
24392439- );
24402440- }
24412441- }
24422442- }
24432443- } catch (error) {
24442444- // Non-fatal: just log and continue without auto-detection
24452445- console.warn(
24462446- "[Upload] Failed to auto-detect meeting time:",
24472447- error instanceof Error ? error.message : "Unknown error",
24482448- );
24492449- }
24502450- }
24512451-24522452- // Create database record
26262626+ // Create database record (without meeting_time_id - will be set later via PATCH)
24532627 db.run(
24542628 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
24552629 [
24562630 transcriptionId,
24572631 user.id,
24582632 classId,
24592459- finalMeetingTimeId,
26332633+ null, // meeting_time_id will be set via PATCH endpoint
24602634 sectionId,
24612635 filename,
24622636 file.name,
···24702644 return Response.json(
24712645 {
24722646 id: transcriptionId,
24732473- meeting_time_id: finalMeetingTimeId,
24742647 message: "Upload successful",
24752648 },
24762649 { status: 201 },
+227
src/lib/voting.ts
···11+import { nanoid } from "nanoid";
22+import db from "../db/schema";
33+44+/**
55+ * Vote for a recording
66+ * Returns true if vote was recorded, false if already voted
77+ */
88+export function voteForRecording(
99+ transcriptionId: string,
1010+ userId: number,
1111+): boolean {
1212+ try {
1313+ const voteId = nanoid();
1414+ db.run(
1515+ "INSERT INTO recording_votes (id, transcription_id, user_id) VALUES (?, ?, ?)",
1616+ [voteId, transcriptionId, userId],
1717+ );
1818+1919+ // Increment vote count on transcription
2020+ db.run(
2121+ "UPDATE transcriptions SET vote_count = vote_count + 1 WHERE id = ?",
2222+ [transcriptionId],
2323+ );
2424+2525+ return true;
2626+ } catch (error) {
2727+ // Unique constraint violation means user already voted
2828+ if (
2929+ error instanceof Error &&
3030+ error.message.includes("UNIQUE constraint failed")
3131+ ) {
3232+ return false;
3333+ }
3434+ throw error;
3535+ }
3636+}
3737+3838+/**
3939+ * Remove vote for a recording
4040+ */
4141+export function removeVote(transcriptionId: string, userId: number): boolean {
4242+ const result = db.run(
4343+ "DELETE FROM recording_votes WHERE transcription_id = ? AND user_id = ?",
4444+ [transcriptionId, userId],
4545+ );
4646+4747+ if (result.changes > 0) {
4848+ // Decrement vote count on transcription
4949+ db.run(
5050+ "UPDATE transcriptions SET vote_count = vote_count - 1 WHERE id = ?",
5151+ [transcriptionId],
5252+ );
5353+ return true;
5454+ }
5555+5656+ return false;
5757+}
5858+5959+/**
6060+ * Get user's vote for a specific class meeting time
6161+ */
6262+export function getUserVoteForMeeting(
6363+ userId: number,
6464+ classId: string,
6565+ meetingTimeId: string,
6666+): string | null {
6767+ const result = db
6868+ .query<
6969+ { transcription_id: string },
7070+ [number, string, string]
7171+ >(
7272+ `SELECT rv.transcription_id
7373+ FROM recording_votes rv
7474+ JOIN transcriptions t ON rv.transcription_id = t.id
7575+ WHERE rv.user_id = ?
7676+ AND t.class_id = ?
7777+ AND t.meeting_time_id = ?
7878+ AND t.status = 'pending'`,
7979+ )
8080+ .get(userId, classId, meetingTimeId);
8181+8282+ return result?.transcription_id || null;
8383+}
8484+8585+/**
8686+ * Get all pending recordings for a class meeting time (filtered by section)
8787+ */
8888+export function getPendingRecordings(
8989+ classId: string,
9090+ meetingTimeId: string,
9191+ sectionId?: string | null,
9292+) {
9393+ // Build query based on whether section filtering is needed
9494+ let query = `SELECT id, user_id, filename, original_filename, vote_count, created_at, section_id
9595+ FROM transcriptions
9696+ WHERE class_id = ?
9797+ AND meeting_time_id = ?
9898+ AND status = 'pending'`;
9999+100100+ const params: (string | null)[] = [classId, meetingTimeId];
101101+102102+ // Filter by section if provided (for voting - section-specific)
103103+ if (sectionId !== undefined) {
104104+ query += " AND (section_id = ? OR section_id IS NULL)";
105105+ params.push(sectionId);
106106+ }
107107+108108+ query += " ORDER BY vote_count DESC, created_at ASC";
109109+110110+ return db
111111+ .query<
112112+ {
113113+ id: string;
114114+ user_id: number;
115115+ filename: string;
116116+ original_filename: string;
117117+ vote_count: number;
118118+ created_at: number;
119119+ section_id: string | null;
120120+ },
121121+ (string | null)[]
122122+ >(query)
123123+ .all(...params);
124124+}
125125+126126+/**
127127+ * Get total enrolled users count for a class
128128+ */
129129+export function getEnrolledUserCount(classId: string): number {
130130+ const result = db
131131+ .query<{ count: number }, [string]>(
132132+ "SELECT COUNT(*) as count FROM class_members WHERE class_id = ?",
133133+ )
134134+ .get(classId);
135135+136136+ return result?.count || 0;
137137+}
138138+139139+/**
140140+ * Check if recording should be auto-submitted
141141+ * Returns winning recording ID if ready, null otherwise
142142+ */
143143+export function checkAutoSubmit(
144144+ classId: string,
145145+ meetingTimeId: string,
146146+ sectionId?: string | null,
147147+): string | null {
148148+ const recordings = getPendingRecordings(classId, meetingTimeId, sectionId);
149149+150150+ if (recordings.length === 0) {
151151+ return null;
152152+ }
153153+154154+ const totalUsers = getEnrolledUserCount(classId);
155155+ const now = Date.now() / 1000; // Current time in seconds
156156+157157+ // Get the recording with most votes
158158+ const topRecording = recordings[0];
159159+ if (!topRecording) return null;
160160+161161+ const uploadedAt = topRecording.created_at;
162162+ const timeSinceUpload = now - uploadedAt;
163163+164164+ // Auto-submit if:
165165+ // 1. 30 minutes have passed since first upload, OR
166166+ // 2. 40% of enrolled users have voted for the top recording
167167+ const thirtyMinutes = 30 * 60; // 30 minutes in seconds
168168+ const voteThreshold = Math.ceil(totalUsers * 0.4);
169169+170170+ if (timeSinceUpload >= thirtyMinutes) {
171171+ console.log(
172172+ `[Voting] Auto-submitting ${topRecording.id} - 30 minutes elapsed`,
173173+ );
174174+ return topRecording.id;
175175+ }
176176+177177+ if (topRecording.vote_count >= voteThreshold) {
178178+ console.log(
179179+ `[Voting] Auto-submitting ${topRecording.id} - reached ${topRecording.vote_count}/${voteThreshold} votes (40% threshold)`,
180180+ );
181181+ return topRecording.id;
182182+ }
183183+184184+ return null;
185185+}
186186+187187+/**
188188+ * Mark a recording as auto-submitted and start transcription
189189+ */
190190+export function markAsAutoSubmitted(transcriptionId: string): void {
191191+ db.run(
192192+ "UPDATE transcriptions SET auto_submitted = 1 WHERE id = ?",
193193+ [transcriptionId],
194194+ );
195195+}
196196+197197+/**
198198+ * Delete a pending recording (only allowed by uploader or admin)
199199+ */
200200+export function deletePendingRecording(
201201+ transcriptionId: string,
202202+ userId: number,
203203+ isAdmin: boolean,
204204+): boolean {
205205+ // Check ownership if not admin
206206+ if (!isAdmin) {
207207+ const recording = db
208208+ .query<{ user_id: number; status: string }, [string]>(
209209+ "SELECT user_id, status FROM transcriptions WHERE id = ?",
210210+ )
211211+ .get(transcriptionId);
212212+213213+ if (!recording || recording.user_id !== userId) {
214214+ return false;
215215+ }
216216+217217+ // Only allow deleting pending recordings
218218+ if (recording.status !== "pending") {
219219+ return false;
220220+ }
221221+ }
222222+223223+ // Delete the recording (cascades to votes)
224224+ db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]);
225225+226226+ return true;
227227+}