馃 distributed transcription service thistle.dunkirk.sh
1import { nanoid } from "nanoid"; 2import db from "../db/schema"; 3 4/** 5 * Vote for a recording 6 * Returns true if vote was recorded, false if already voted 7 */ 8export function voteForRecording( 9 transcriptionId: string, 10 userId: number, 11): boolean { 12 try { 13 const voteId = nanoid(); 14 db.run( 15 "INSERT INTO recording_votes (id, transcription_id, user_id) VALUES (?, ?, ?)", 16 [voteId, transcriptionId, userId], 17 ); 18 19 // Increment vote count on transcription 20 db.run( 21 "UPDATE transcriptions SET vote_count = vote_count + 1 WHERE id = ?", 22 [transcriptionId], 23 ); 24 25 return true; 26 } catch (error) { 27 // Unique constraint violation means user already voted 28 if ( 29 error instanceof Error && 30 error.message.includes("UNIQUE constraint failed") 31 ) { 32 return false; 33 } 34 throw error; 35 } 36} 37 38/** 39 * Remove vote for a recording 40 */ 41export function removeVote(transcriptionId: string, userId: number): boolean { 42 const result = db.run( 43 "DELETE FROM recording_votes WHERE transcription_id = ? AND user_id = ?", 44 [transcriptionId, userId], 45 ); 46 47 if (result.changes > 0) { 48 // Decrement vote count on transcription 49 db.run( 50 "UPDATE transcriptions SET vote_count = vote_count - 1 WHERE id = ?", 51 [transcriptionId], 52 ); 53 return true; 54 } 55 56 return false; 57} 58 59/** 60 * Get user's vote for a specific class meeting time 61 */ 62export function getUserVoteForMeeting( 63 userId: number, 64 classId: string, 65 meetingTimeId: string, 66): string | null { 67 const result = db 68 .query< 69 { transcription_id: string }, 70 [number, string, string] 71 >( 72 `SELECT rv.transcription_id 73 FROM recording_votes rv 74 JOIN transcriptions t ON rv.transcription_id = t.id 75 WHERE rv.user_id = ? 76 AND t.class_id = ? 77 AND t.meeting_time_id = ? 78 AND t.status = 'pending'`, 79 ) 80 .get(userId, classId, meetingTimeId); 81 82 return result?.transcription_id || null; 83} 84 85/** 86 * Get all pending recordings for a class meeting time (filtered by section) 87 */ 88export function getPendingRecordings( 89 classId: string, 90 meetingTimeId: string, 91 sectionId?: string | null, 92) { 93 // Build query based on whether section filtering is needed 94 let query = `SELECT id, user_id, filename, original_filename, vote_count, created_at, section_id 95 FROM transcriptions 96 WHERE class_id = ? 97 AND meeting_time_id = ? 98 AND status = 'pending'`; 99 100 const params: (string | null)[] = [classId, meetingTimeId]; 101 102 // Filter by section if provided (for voting - section-specific) 103 if (sectionId !== undefined) { 104 query += " AND (section_id = ? OR section_id IS NULL)"; 105 params.push(sectionId); 106 } 107 108 query += " ORDER BY vote_count DESC, created_at ASC"; 109 110 return db 111 .query< 112 { 113 id: string; 114 user_id: number; 115 filename: string; 116 original_filename: string; 117 vote_count: number; 118 created_at: number; 119 section_id: string | null; 120 }, 121 (string | null)[] 122 >(query) 123 .all(...params); 124} 125 126/** 127 * Get total enrolled users count for a class 128 */ 129export function getEnrolledUserCount(classId: string): number { 130 const result = db 131 .query<{ count: number }, [string]>( 132 "SELECT COUNT(*) as count FROM class_members WHERE class_id = ?", 133 ) 134 .get(classId); 135 136 return result?.count || 0; 137} 138 139/** 140 * Check if recording should be auto-submitted 141 * Returns winning recording ID if ready, null otherwise 142 */ 143export function checkAutoSubmit( 144 classId: string, 145 meetingTimeId: string, 146 sectionId?: string | null, 147): string | null { 148 const recordings = getPendingRecordings(classId, meetingTimeId, sectionId); 149 150 if (recordings.length === 0) { 151 return null; 152 } 153 154 const totalUsers = getEnrolledUserCount(classId); 155 const now = Date.now() / 1000; // Current time in seconds 156 157 // Get the recording with most votes 158 const topRecording = recordings[0]; 159 if (!topRecording) return null; 160 161 const uploadedAt = topRecording.created_at; 162 const timeSinceUpload = now - uploadedAt; 163 164 // Auto-submit if: 165 // 1. 30 minutes have passed since first upload, OR 166 // 2. 40% of enrolled users have voted for the top recording 167 const thirtyMinutes = 30 * 60; // 30 minutes in seconds 168 const voteThreshold = Math.ceil(totalUsers * 0.4); 169 170 if (timeSinceUpload >= thirtyMinutes) { 171 console.log( 172 `[Voting] Auto-submitting ${topRecording.id} - 30 minutes elapsed`, 173 ); 174 return topRecording.id; 175 } 176 177 if (topRecording.vote_count >= voteThreshold) { 178 console.log( 179 `[Voting] Auto-submitting ${topRecording.id} - reached ${topRecording.vote_count}/${voteThreshold} votes (40% threshold)`, 180 ); 181 return topRecording.id; 182 } 183 184 return null; 185} 186 187/** 188 * Mark a recording as auto-submitted and start transcription 189 */ 190export function markAsAutoSubmitted(transcriptionId: string): void { 191 db.run( 192 "UPDATE transcriptions SET auto_submitted = 1 WHERE id = ?", 193 [transcriptionId], 194 ); 195} 196 197/** 198 * Delete a pending recording (only allowed by uploader or admin) 199 */ 200export function deletePendingRecording( 201 transcriptionId: string, 202 userId: number, 203 isAdmin: boolean, 204): boolean { 205 // Check ownership if not admin 206 if (!isAdmin) { 207 const recording = db 208 .query<{ user_id: number; status: string }, [string]>( 209 "SELECT user_id, status FROM transcriptions WHERE id = ?", 210 ) 211 .get(transcriptionId); 212 213 if (!recording || recording.user_id !== userId) { 214 return false; 215 } 216 217 // Only allow deleting pending recordings 218 if (recording.status !== "pending") { 219 return false; 220 } 221 } 222 223 // Delete the recording (cascades to votes) 224 db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]); 225 226 return true; 227}