🪻 distributed transcription service thistle.dunkirk.sh

feat: add voting to auto approve transcripts

dunkirk.sh 07e5eeb6 c0f6c375

verified
+1093 -136
+27
src/components/class-view.ts
··· 2 import { customElement, state } from "lit/decorators.js"; 3 import "./upload-recording-modal.ts"; 4 import "./vtt-viewer.ts"; 5 6 interface Class { 7 id: string; ··· 492 ); 493 } 494 495 return filtered; 496 } 497 ··· 630 </button> 631 </div> 632 633 ${ 634 this.filteredTranscriptions.length === 0 635 ? html`
··· 2 import { customElement, state } from "lit/decorators.js"; 3 import "./upload-recording-modal.ts"; 4 import "./vtt-viewer.ts"; 5 + import "./pending-recordings-view.ts"; 6 7 interface Class { 8 id: string; ··· 493 ); 494 } 495 496 + // Exclude pending recordings (they're shown in the voting section) 497 + filtered = filtered.filter((t) => t.status !== "pending"); 498 + 499 return filtered; 500 } 501 ··· 634 </button> 635 </div> 636 637 + <!-- Pending Recordings for Voting --> 638 + ${ 639 + this.meetingTimes.map((meeting) => { 640 + const pendingCount = this.transcriptions.filter( 641 + (t) => t.meeting_time_id === meeting.id && t.status === "pending", 642 + ).length; 643 + 644 + // Only show if there are pending recordings 645 + if (pendingCount === 0) return ""; 646 + 647 + return html` 648 + <div style="margin-bottom: 2rem;"> 649 + <pending-recordings-view 650 + .classId=${this.classId} 651 + .meetingTimeId=${meeting.id} 652 + .meetingTimeLabel=${meeting.label} 653 + ></pending-recordings-view> 654 + </div> 655 + `; 656 + }) 657 + } 658 + 659 + <!-- Completed/Processing Transcriptions --> 660 ${ 661 this.filteredTranscriptions.length === 0 662 ? html`
+428
src/components/pending-recordings-view.ts
···
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + interface PendingRecording { 5 + id: string; 6 + user_id: number; 7 + filename: string; 8 + original_filename: string; 9 + vote_count: number; 10 + created_at: number; 11 + } 12 + 13 + interface RecordingsData { 14 + recordings: PendingRecording[]; 15 + total_users: number; 16 + user_vote: string | null; 17 + vote_threshold: number; 18 + winning_recording_id: string | null; 19 + } 20 + 21 + @customElement("pending-recordings-view") 22 + export class PendingRecordingsView extends LitElement { 23 + @property({ type: String }) classId = ""; 24 + @property({ type: String }) meetingTimeId = ""; 25 + @property({ type: String }) meetingTimeLabel = ""; 26 + 27 + @state() private recordings: PendingRecording[] = []; 28 + @state() private userVote: string | null = null; 29 + @state() private voteThreshold = 0; 30 + @state() private winningRecordingId: string | null = null; 31 + @state() private error: string | null = null; 32 + @state() private timeRemaining = ""; 33 + 34 + private refreshInterval?: number; 35 + private loadingInProgress = false; 36 + 37 + static override styles = css` 38 + :host { 39 + display: block; 40 + padding: 1rem; 41 + } 42 + 43 + .container { 44 + max-width: 56rem; 45 + margin: 0 auto; 46 + } 47 + 48 + h2 { 49 + color: var(--text); 50 + margin-bottom: 0.5rem; 51 + } 52 + 53 + .info { 54 + color: var(--paynes-gray); 55 + font-size: 0.875rem; 56 + margin-bottom: 1.5rem; 57 + } 58 + 59 + .stats { 60 + display: flex; 61 + gap: 2rem; 62 + margin-bottom: 1.5rem; 63 + padding: 1rem; 64 + background: color-mix(in srgb, var(--primary) 5%, transparent); 65 + border-radius: 8px; 66 + } 67 + 68 + .stat { 69 + display: flex; 70 + flex-direction: column; 71 + gap: 0.25rem; 72 + } 73 + 74 + .stat-label { 75 + font-size: 0.75rem; 76 + color: var(--paynes-gray); 77 + text-transform: uppercase; 78 + letter-spacing: 0.05em; 79 + } 80 + 81 + .stat-value { 82 + font-size: 1.5rem; 83 + font-weight: 600; 84 + color: var(--text); 85 + } 86 + 87 + .recordings-list { 88 + display: flex; 89 + flex-direction: column; 90 + gap: 1rem; 91 + } 92 + 93 + .recording-card { 94 + border: 2px solid var(--secondary); 95 + border-radius: 8px; 96 + padding: 1rem; 97 + transition: all 0.2s; 98 + } 99 + 100 + .recording-card.voted { 101 + border-color: var(--accent); 102 + background: color-mix(in srgb, var(--accent) 5%, transparent); 103 + } 104 + 105 + .recording-card.winning { 106 + border-color: var(--accent); 107 + background: color-mix(in srgb, var(--accent) 10%, transparent); 108 + } 109 + 110 + .recording-header { 111 + display: flex; 112 + justify-content: space-between; 113 + align-items: center; 114 + margin-bottom: 0.75rem; 115 + } 116 + 117 + .recording-info { 118 + flex: 1; 119 + } 120 + 121 + .recording-name { 122 + font-weight: 600; 123 + color: var(--text); 124 + margin-bottom: 0.25rem; 125 + } 126 + 127 + .recording-meta { 128 + font-size: 0.75rem; 129 + color: var(--paynes-gray); 130 + } 131 + 132 + .vote-section { 133 + display: flex; 134 + align-items: center; 135 + gap: 1rem; 136 + } 137 + 138 + .vote-count { 139 + font-size: 1.25rem; 140 + font-weight: 600; 141 + color: var(--accent); 142 + min-width: 3rem; 143 + text-align: center; 144 + } 145 + 146 + .vote-button { 147 + padding: 0.5rem 1rem; 148 + border-radius: 6px; 149 + font-size: 0.875rem; 150 + font-weight: 500; 151 + cursor: pointer; 152 + transition: all 0.2s; 153 + border: 2px solid var(--secondary); 154 + background: var(--background); 155 + color: var(--text); 156 + } 157 + 158 + .vote-button:hover:not(:disabled) { 159 + border-color: var(--accent); 160 + background: color-mix(in srgb, var(--accent) 10%, transparent); 161 + } 162 + 163 + .vote-button.voted { 164 + border-color: var(--accent); 165 + background: var(--accent); 166 + color: var(--white); 167 + } 168 + 169 + .vote-button:disabled { 170 + opacity: 0.5; 171 + cursor: not-allowed; 172 + } 173 + 174 + .delete-button { 175 + padding: 0.5rem; 176 + border: none; 177 + background: transparent; 178 + color: var(--paynes-gray); 179 + cursor: pointer; 180 + border-radius: 4px; 181 + transition: all 0.2s; 182 + } 183 + 184 + .delete-button:hover { 185 + background: color-mix(in srgb, red 10%, transparent); 186 + color: red; 187 + } 188 + 189 + .winning-badge { 190 + background: var(--accent); 191 + color: var(--white); 192 + padding: 0.25rem 0.75rem; 193 + border-radius: 12px; 194 + font-size: 0.75rem; 195 + font-weight: 600; 196 + } 197 + 198 + .error { 199 + background: color-mix(in srgb, red 10%, transparent); 200 + border: 1px solid red; 201 + color: red; 202 + padding: 0.75rem; 203 + border-radius: 4px; 204 + margin-bottom: 1rem; 205 + font-size: 0.875rem; 206 + } 207 + 208 + .empty-state { 209 + text-align: center; 210 + padding: 3rem 1rem; 211 + color: var(--paynes-gray); 212 + } 213 + 214 + .audio-player { 215 + margin-top: 0.75rem; 216 + } 217 + 218 + audio { 219 + width: 100%; 220 + height: 2.5rem; 221 + } 222 + `; 223 + 224 + override connectedCallback() { 225 + super.connectedCallback(); 226 + this.loadRecordings(); 227 + // Refresh every 10 seconds 228 + this.refreshInterval = setInterval(() => this.loadRecordings(), 10000); 229 + } 230 + 231 + override disconnectedCallback() { 232 + super.disconnectedCallback(); 233 + if (this.refreshInterval) { 234 + clearInterval(this.refreshInterval); 235 + } 236 + } 237 + 238 + private async loadRecordings() { 239 + if (this.loadingInProgress) return; 240 + 241 + this.loadingInProgress = true; 242 + 243 + try { 244 + const response = await fetch( 245 + `/api/classes/${this.classId}/meetings/${this.meetingTimeId}/recordings`, 246 + ); 247 + 248 + if (!response.ok) { 249 + throw new Error("Failed to load recordings"); 250 + } 251 + 252 + const data: RecordingsData = await response.json(); 253 + this.recordings = data.recordings; 254 + this.userVote = data.user_vote; 255 + this.voteThreshold = data.vote_threshold; 256 + this.winningRecordingId = data.winning_recording_id; 257 + 258 + // Calculate time remaining for first recording 259 + if (this.recordings.length > 0 && this.recordings[0]) { 260 + const uploadedAt = this.recordings[0].created_at; 261 + const now = Date.now() / 1000; 262 + const elapsed = now - uploadedAt; 263 + const remaining = 30 * 60 - elapsed; // 30 minutes 264 + 265 + if (remaining > 0) { 266 + const minutes = Math.floor(remaining / 60); 267 + const seconds = Math.floor(remaining % 60); 268 + this.timeRemaining = `${minutes}:${seconds.toString().padStart(2, "0")}`; 269 + } else { 270 + this.timeRemaining = "Auto-submitting..."; 271 + } 272 + } 273 + 274 + this.error = null; 275 + } catch (error) { 276 + this.error = 277 + error instanceof Error ? error.message : "Failed to load recordings"; 278 + } finally { 279 + this.loadingInProgress = false; 280 + } 281 + } 282 + 283 + private async handleVote(recordingId: string) { 284 + try { 285 + const response = await fetch(`/api/recordings/${recordingId}/vote`, { 286 + method: "POST", 287 + }); 288 + 289 + if (!response.ok) { 290 + throw new Error("Failed to vote"); 291 + } 292 + 293 + const data = await response.json(); 294 + 295 + // If a winner was selected, reload the page to show it in transcriptions 296 + if (data.winning_recording_id) { 297 + window.location.reload(); 298 + } else { 299 + // Just reload recordings to show updated votes 300 + await this.loadRecordings(); 301 + } 302 + } catch (error) { 303 + this.error = 304 + error instanceof Error ? error.message : "Failed to vote"; 305 + } 306 + } 307 + 308 + private async handleDelete(recordingId: string) { 309 + if (!confirm("Delete this recording?")) { 310 + return; 311 + } 312 + 313 + try { 314 + const response = await fetch(`/api/recordings/${recordingId}`, { 315 + method: "DELETE", 316 + }); 317 + 318 + if (!response.ok) { 319 + throw new Error("Failed to delete recording"); 320 + } 321 + 322 + await this.loadRecordings(); 323 + } catch (error) { 324 + this.error = 325 + error instanceof Error ? error.message : "Failed to delete recording"; 326 + } 327 + } 328 + 329 + private formatTimeAgo(timestamp: number): string { 330 + const now = Date.now() / 1000; 331 + const diff = now - timestamp; 332 + 333 + if (diff < 60) return "just now"; 334 + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 335 + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 336 + return `${Math.floor(diff / 86400)}d ago`; 337 + } 338 + 339 + override render() { 340 + return html` 341 + <div class="container"> 342 + <h2>Pending Recordings - ${this.meetingTimeLabel}</h2> 343 + <p class="info"> 344 + Vote for the best quality recording. The winner will be automatically transcribed when 40% of class votes or after 30 minutes. 345 + </p> 346 + 347 + ${this.error ? html`<div class="error">${this.error}</div>` : ""} 348 + 349 + ${ 350 + this.recordings.length > 0 351 + ? html` 352 + <div class="stats"> 353 + <div class="stat"> 354 + <div class="stat-label">Recordings</div> 355 + <div class="stat-value">${this.recordings.length}</div> 356 + </div> 357 + <div class="stat"> 358 + <div class="stat-label">Vote Threshold</div> 359 + <div class="stat-value">${this.voteThreshold} votes</div> 360 + </div> 361 + <div class="stat"> 362 + <div class="stat-label">Time Remaining</div> 363 + <div class="stat-value">${this.timeRemaining}</div> 364 + </div> 365 + </div> 366 + 367 + <div class="recordings-list"> 368 + ${this.recordings.map( 369 + (recording) => html` 370 + <div class="recording-card ${this.userVote === recording.id ? "voted" : ""} ${this.winningRecordingId === recording.id ? "winning" : ""}"> 371 + <div class="recording-header"> 372 + <div class="recording-info"> 373 + <div class="recording-name">${recording.original_filename}</div> 374 + <div class="recording-meta"> 375 + Uploaded ${this.formatTimeAgo(recording.created_at)} 376 + </div> 377 + </div> 378 + 379 + <div class="vote-section"> 380 + ${ 381 + this.winningRecordingId === recording.id 382 + ? html`<span class="winning-badge">✨ Selected</span>` 383 + : "" 384 + } 385 + 386 + <div class="vote-count"> 387 + ${recording.vote_count} ${recording.vote_count === 1 ? "vote" : "votes"} 388 + </div> 389 + 390 + <button 391 + class="vote-button ${this.userVote === recording.id ? "voted" : ""}" 392 + @click=${() => this.handleVote(recording.id)} 393 + ?disabled=${this.winningRecordingId !== null} 394 + > 395 + ${this.userVote === recording.id ? "✓ Voted" : "Vote"} 396 + </button> 397 + 398 + <button 399 + class="delete-button" 400 + @click=${() => this.handleDelete(recording.id)} 401 + title="Delete recording" 402 + > 403 + 🗑️ 404 + </button> 405 + </div> 406 + </div> 407 + 408 + <div class="audio-player"> 409 + <audio controls preload="none"> 410 + <source src="/api/transcriptions/${recording.id}/audio" type="audio/mpeg"> 411 + </audio> 412 + </div> 413 + </div> 414 + `, 415 + )} 416 + </div> 417 + ` 418 + : html` 419 + <div class="empty-state"> 420 + <p>No recordings uploaded yet for this meeting time.</p> 421 + <p>Upload a recording to get started!</p> 422 + </div> 423 + ` 424 + } 425 + </div> 426 + `; 427 + } 428 + }
+138 -61
src/components/upload-recording-modal.ts
··· 23 @state() private selectedMeetingTimeId: string | null = null; 24 @state() private selectedSectionId: string | null = null; 25 @state() private uploading = false; 26 @state() private error: string | null = null; 27 @state() private detectedMeetingTime: string | null = null; 28 @state() private detectingMeetingTime = false; 29 30 static override styles = css` 31 :host { ··· 285 this.error = null; 286 this.detectedMeetingTime = null; 287 this.selectedMeetingTimeId = null; 288 289 - // Auto-detect meeting time from file metadata 290 if (this.selectedFile && this.classId) { 291 - await this.detectMeetingTime(); 292 } 293 } 294 } 295 296 private async detectMeetingTime() { 297 if (!this.selectedFile || !this.classId) return; 298 ··· 344 this.selectedSectionId = select.value || null; 345 } 346 347 - private handleClose() { 348 - if (this.uploading) return; 349 - this.open = false; 350 - this.selectedFile = null; 351 - this.selectedMeetingTimeId = null; 352 - this.selectedSectionId = null; 353 - this.error = null; 354 - this.detectedMeetingTime = null; 355 - this.detectingMeetingTime = false; 356 - this.dispatchEvent(new CustomEvent("close")); 357 - } 358 - 359 - private async handleUpload() { 360 - if (!this.selectedFile) { 361 - this.error = "Please select a file to upload"; 362 - return; 363 - } 364 365 - if (!this.selectedMeetingTimeId) { 366 - this.error = "Please select a meeting time"; 367 - return; 368 - } 369 - 370 - this.uploading = true; 371 this.error = null; 372 373 try { 374 - const formData = new FormData(); 375 - formData.append("audio", this.selectedFile); 376 - formData.append("class_id", this.classId); 377 - formData.append("meeting_time_id", this.selectedMeetingTimeId); 378 - 379 - // Use user's section by default, or allow override 380 - const sectionToUse = this.selectedSectionId || this.userSection; 381 - if (sectionToUse) { 382 - formData.append("section_id", sectionToUse); 383 - } 384 - 385 - const response = await fetch("/api/transcriptions", { 386 - method: "POST", 387 - body: formData, 388 - }); 389 390 if (!response.ok) { 391 const data = await response.json(); 392 - throw new Error(data.error || "Upload failed"); 393 } 394 395 - // Success 396 this.dispatchEvent(new CustomEvent("upload-success")); 397 this.handleClose(); 398 } catch (error) { 399 - console.error("Upload failed:", error); 400 - this.error = 401 - error instanceof Error 402 - ? error.message 403 - : "Upload failed. Please try again."; 404 - } finally { 405 - this.uploading = false; 406 } 407 } 408 409 override render() { ··· 508 ` 509 : "" 510 } 511 </form> 512 513 <div class="modal-footer"> 514 - <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading}> 515 Cancel 516 </button> 517 - <button 518 - class="btn-upload" 519 - @click=${this.handleUpload} 520 - ?disabled=${this.uploading || !this.selectedFile || !this.selectedMeetingTimeId} 521 - > 522 - ${ 523 - this.uploading 524 - ? html`<span class="uploading-text">Uploading...</span>` 525 - : "Upload" 526 - } 527 - </button> 528 </div> 529 </div> 530 </div>
··· 23 @state() private selectedMeetingTimeId: string | null = null; 24 @state() private selectedSectionId: string | null = null; 25 @state() private uploading = false; 26 + @state() private uploadProgress = 0; 27 @state() private error: string | null = null; 28 @state() private detectedMeetingTime: string | null = null; 29 @state() private detectingMeetingTime = false; 30 + @state() private uploadComplete = false; 31 + @state() private uploadedTranscriptionId: string | null = null; 32 + @state() private submitting = false; 33 34 static override styles = css` 35 :host { ··· 289 this.error = null; 290 this.detectedMeetingTime = null; 291 this.selectedMeetingTimeId = null; 292 + this.uploadComplete = false; 293 + this.uploadedTranscriptionId = null; 294 + this.submitting = false; 295 296 if (this.selectedFile && this.classId) { 297 + // Start both detection and upload in parallel 298 + this.detectMeetingTime(); 299 + this.startBackgroundUpload(); 300 } 301 } 302 } 303 304 + private async startBackgroundUpload() { 305 + if (!this.selectedFile) return; 306 + 307 + this.uploading = true; 308 + this.uploadProgress = 0; 309 + 310 + try { 311 + const formData = new FormData(); 312 + formData.append("audio", this.selectedFile); 313 + formData.append("class_id", this.classId); 314 + 315 + // Use user's section by default, or allow override 316 + const sectionToUse = this.selectedSectionId || this.userSection; 317 + if (sectionToUse) { 318 + formData.append("section_id", sectionToUse); 319 + } 320 + 321 + const xhr = new XMLHttpRequest(); 322 + 323 + // Track upload progress 324 + xhr.upload.addEventListener("progress", (e) => { 325 + if (e.lengthComputable) { 326 + this.uploadProgress = Math.round((e.loaded / e.total) * 100); 327 + } 328 + }); 329 + 330 + // Handle completion 331 + xhr.addEventListener("load", () => { 332 + if (xhr.status >= 200 && xhr.status < 300) { 333 + this.uploadComplete = true; 334 + this.uploading = false; 335 + const response = JSON.parse(xhr.responseText); 336 + this.uploadedTranscriptionId = response.id; 337 + } else { 338 + this.uploading = false; 339 + const response = JSON.parse(xhr.responseText); 340 + this.error = response.error || "Upload failed"; 341 + } 342 + }); 343 + 344 + // Handle errors 345 + xhr.addEventListener("error", () => { 346 + this.uploading = false; 347 + this.error = "Upload failed. Please try again."; 348 + }); 349 + 350 + xhr.open("POST", "/api/transcriptions"); 351 + xhr.send(formData); 352 + } catch (error) { 353 + console.error("Upload failed:", error); 354 + this.uploading = false; 355 + this.error = 356 + error instanceof Error 357 + ? error.message 358 + : "Upload failed. Please try again."; 359 + } 360 + } 361 + 362 private async detectMeetingTime() { 363 if (!this.selectedFile || !this.classId) return; 364 ··· 410 this.selectedSectionId = select.value || null; 411 } 412 413 + private async handleSubmit() { 414 + if (!this.uploadedTranscriptionId || !this.selectedMeetingTimeId) return; 415 416 + this.submitting = true; 417 this.error = null; 418 419 try { 420 + const response = await fetch( 421 + `/api/transcriptions/${this.uploadedTranscriptionId}/meeting-time`, 422 + { 423 + method: "PATCH", 424 + headers: { "Content-Type": "application/json" }, 425 + body: JSON.stringify({ 426 + meeting_time_id: this.selectedMeetingTimeId, 427 + }), 428 + }, 429 + ); 430 431 if (!response.ok) { 432 const data = await response.json(); 433 + this.error = data.error || "Failed to update meeting time"; 434 + this.submitting = false; 435 + return; 436 } 437 438 + // Success - close modal and refresh 439 this.dispatchEvent(new CustomEvent("upload-success")); 440 this.handleClose(); 441 } catch (error) { 442 + console.error("Failed to update meeting time:", error); 443 + this.error = "Failed to update meeting time"; 444 + this.submitting = false; 445 } 446 + } 447 + 448 + private handleClose() { 449 + if (this.uploading || this.submitting) return; 450 + this.open = false; 451 + this.selectedFile = null; 452 + this.selectedMeetingTimeId = null; 453 + this.selectedSectionId = null; 454 + this.error = null; 455 + this.detectedMeetingTime = null; 456 + this.detectingMeetingTime = false; 457 + this.uploadComplete = false; 458 + this.uploadProgress = 0; 459 + this.uploadedTranscriptionId = null; 460 + this.submitting = false; 461 + this.dispatchEvent(new CustomEvent("close")); 462 } 463 464 override render() { ··· 563 ` 564 : "" 565 } 566 + 567 + ${ 568 + this.uploading || this.uploadComplete 569 + ? html` 570 + <div class="form-group"> 571 + <label>Upload Status</label> 572 + <div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;"> 573 + ${ 574 + this.uploadComplete 575 + ? html` 576 + <div style="color: green; font-weight: 500;"> 577 + ✓ Upload complete! Select a meeting time to continue. 578 + </div> 579 + ` 580 + : html` 581 + <div style="color: var(--text); font-weight: 500; margin-bottom: 0.5rem;"> 582 + Uploading... ${this.uploadProgress}% 583 + </div> 584 + <div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;"> 585 + <div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div> 586 + </div> 587 + ` 588 + } 589 + </div> 590 + </div> 591 + ` 592 + : "" 593 + } 594 </form> 595 596 <div class="modal-footer"> 597 + <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}> 598 Cancel 599 </button> 600 + ${this.uploadComplete && this.selectedMeetingTimeId ? html` 601 + <button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}> 602 + ${this.submitting ? "Submitting..." : "Confirm & Submit"} 603 + </button> 604 + ` : ""} 605 </div> 606 </div> 607 </div>
+25
src/db/schema.ts
··· 253 CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id); 254 `, 255 }, 256 ]; 257 258 function 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 ]; 282 283 function getCurrentVersion(): number {
+248 -75
src/index.ts
··· 92 WhisperServiceManager, 93 } from "./lib/transcription"; 94 import { 95 - extractAudioCreationDate, 96 findMatchingMeetingTime, 97 getDayName, 98 } from "./lib/audio-metadata"; 99 import { 100 validateClassId, 101 validateCourseCode, ··· 2113 2114 let creationDate: Date | null = null; 2115 2116 - // 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 } 2126 2127 - // 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 }, 2197 "/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; 2343 2344 if (!file) throw ValidationErrors.missingField("audio"); ··· 2406 const uploadDir = "./uploads"; 2407 await Bun.write(`${uploadDir}/${filename}`, file); 2408 2409 - // 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"; 94 import { 95 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"; 108 import { 109 validateClassId, 110 validateCourseCode, ··· 2122 2123 let creationDate: Date | null = null; 2124 2125 + // 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 } 2135 2136 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; 2559 const sectionId = formData.get("section_id") as string | null; 2560 2561 if (!file) throw ValidationErrors.missingField("audio"); ··· 2623 const uploadDir = "./uploads"; 2624 await Bun.write(`${uploadDir}/${filename}`, file); 2625 2626 + // Create database record (without meeting_time_id - will be set later via PATCH) 2627 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, 2647 message: "Upload successful", 2648 }, 2649 { status: 201 },
+227
src/lib/voting.ts
···
··· 1 + import { nanoid } from "nanoid"; 2 + import db from "../db/schema"; 3 + 4 + /** 5 + * Vote for a recording 6 + * Returns true if vote was recorded, false if already voted 7 + */ 8 + export 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 + */ 41 + export 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 + */ 62 + export 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 + */ 88 + export 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 + */ 129 + export 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 + */ 143 + export 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 + */ 190 + export 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 + */ 200 + export 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 + }