🪻 distributed transcription service thistle.dunkirk.sh

Compare changes

Choose any two refs to compare.

+39
scripts/remove-from-classes.ts
···
···
+
#!/usr/bin/env bun
+
+
import db from "../src/db/schema";
+
+
const email = process.argv[2];
+
+
if (!email) {
+
console.error("Usage: bun scripts/remove-from-classes.ts <email>");
+
console.error(" Removes a user from all their enrolled classes");
+
process.exit(1);
+
}
+
+
const user = db
+
.query<{ id: number; email: string }, [string]>(
+
"SELECT id, email FROM users WHERE email = ?",
+
)
+
.get(email);
+
+
if (!user) {
+
console.error(`User with email ${email} not found`);
+
process.exit(1);
+
}
+
+
// Get current enrollments
+
const enrollments = db
+
.query<{ class_id: string }, [number]>(
+
"SELECT class_id FROM class_members WHERE user_id = ?",
+
)
+
.all(user.id);
+
+
if (enrollments.length === 0) {
+
console.log(`User ${email} is not enrolled in any classes`);
+
process.exit(0);
+
}
+
+
// Remove from all classes
+
db.run("DELETE FROM class_members WHERE user_id = ?", [user.id]);
+
+
console.log(`✅ Successfully removed ${email} from ${enrollments.length} class(es)`);
+215 -15
src/components/admin-classes.ts
···
@state() activeTab: "classes" | "waitlist" = "classes";
@state() approvingEntry: WaitlistEntry | null = null;
@state() showModal = false;
@state() meetingTimes: MeetingTime[] = [];
@state() editingClass = {
courseCode: "",
courseName: "",
···
this.showModal = true;
}
private getFilteredClasses() {
if (!this.searchTerm) return this.classes;
···
}
${this.showModal ? this.renderApprovalModal() : ""}
`;
}
···
<div class="classes-grid">
${filteredClasses.map(
(cls) => html`
-
<div class="class-card ${cls.archived ? "archived" : ""}">
<div class="class-header">
<div class="class-info">
<div class="course-code">${cls.course_code}</div>
···
${cls.archived ? html`<span class="badge archived">Archived</span>` : ""}
</div>
</div>
-
<div class="actions">
-
<button
-
class="btn-archive"
-
@click=${() => this.handleToggleArchive(cls.id)}
-
>
-
${cls.archived ? "Unarchive" : "Archive"}
-
</button>
-
<button
-
class="btn-delete"
-
@click=${() => this.handleDeleteClick(cls.id, "class")}
-
>
-
${this.getDeleteButtonText(cls.id, "class")}
-
</button>
-
</div>
</div>
</div>
`,
···
`;
}
private renderWaitlist() {
return html`
${
···
this.meetingTimes = e.detail;
}
private handleClassFieldInput(field: string, e: Event) {
const value = (e.target as HTMLInputElement | HTMLSelectElement).value;
this.editingClass = { ...this.editingClass, [field]: value };
···
this.showModal = false;
this.approvingEntry = null;
this.meetingTimes = [];
this.editingClass = {
courseCode: "",
courseName: "",
···
semester: this.editingClass.semester,
year: this.editingClass.year,
meeting_times: labels,
}),
});
···
.value=${this.meetingTimes}
@change=${this.handleMeetingTimesChange}
></meeting-time-picker>
</div>
</div>
···
@state() activeTab: "classes" | "waitlist" = "classes";
@state() approvingEntry: WaitlistEntry | null = null;
@state() showModal = false;
+
@state() showClassSettingsModal = false;
+
@state() editingClassId: string | null = null;
+
@state() editingClassInfo: Class | null = null;
+
@state() editingClassSections: { id: string; section_number: string }[] = [];
+
@state() newSectionNumber = "";
@state() meetingTimes: MeetingTime[] = [];
+
@state() sections: string[] = [];
@state() editingClass = {
courseCode: "",
courseName: "",
···
this.showModal = true;
}
+
private async handleEditSections(classId: string) {
+
try {
+
const response = await fetch(`/api/classes/${classId}`);
+
if (!response.ok) throw new Error("Failed to load class");
+
+
const data = await response.json();
+
this.editingClassId = classId;
+
this.editingClassInfo = data.class;
+
this.editingClassSections = data.sections || [];
+
this.newSectionNumber = "";
+
this.showClassSettingsModal = true;
+
} catch {
+
this.error = "Failed to load class details";
+
}
+
}
+
+
private async handleAddSection() {
+
if (!this.newSectionNumber.trim() || !this.editingClassId) return;
+
+
try {
+
const response = await fetch(`/api/classes/${this.editingClassId}/sections`, {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ section_number: this.newSectionNumber.trim() }),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to add section";
+
return;
+
}
+
+
const newSection = await response.json();
+
this.editingClassSections = [...this.editingClassSections, newSection];
+
this.newSectionNumber = "";
+
} catch {
+
this.error = "Failed to add section";
+
}
+
}
+
+
private async handleDeleteSection(sectionId: string) {
+
if (!this.editingClassId) return;
+
+
try {
+
const response = await fetch(`/api/classes/${this.editingClassId}/sections/${sectionId}`, {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to delete section";
+
return;
+
}
+
+
this.editingClassSections = this.editingClassSections.filter(s => s.id !== sectionId);
+
} catch {
+
this.error = "Failed to delete section";
+
}
+
}
+
+
private handleCloseSectionsModal() {
+
this.showClassSettingsModal = false;
+
this.editingClassId = null;
+
this.editingClassInfo = null;
+
this.editingClassSections = [];
+
this.newSectionNumber = "";
+
this.loadData();
+
}
+
+
private getFilteredClasses() {
if (!this.searchTerm) return this.classes;
···
}
${this.showModal ? this.renderApprovalModal() : ""}
+
${this.showClassSettingsModal ? this.renderClassSettingsModal() : ""}
`;
}
···
<div class="classes-grid">
${filteredClasses.map(
(cls) => html`
+
<div
+
class="class-card ${cls.archived ? "archived" : ""}"
+
@click=${() => this.handleEditSections(cls.id)}
+
style="cursor: pointer;"
+
>
<div class="class-header">
<div class="class-info">
<div class="course-code">${cls.course_code}</div>
···
${cls.archived ? html`<span class="badge archived">Archived</span>` : ""}
</div>
</div>
</div>
</div>
`,
···
`;
}
+
private renderClassSettingsModal() {
+
if (!this.showClassSettingsModal || !this.editingClassInfo) return html``;
+
+
return html`
+
<div class="modal-overlay" @click=${this.handleCloseSectionsModal}>
+
<div class="modal" @click=${(e: Event) => e.stopPropagation()} style="max-width: 48rem;">
+
<div class="modal-header">
+
<h2 class="modal-title">${this.editingClassInfo.course_code} - ${this.editingClassInfo.name}</h2>
+
<button class="close-btn" @click=${this.handleCloseSectionsModal} type="button">×</button>
+
</div>
+
+
<div class="tabs" style="margin-bottom: 1.5rem;">
+
<div style="display: flex; gap: 0.5rem; border-bottom: 2px solid var(--secondary);">
+
<button
+
style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: none; border-bottom: 2px solid var(--primary); font-weight: 600; cursor: pointer; margin-bottom: -2px;"
+
>
+
Sections
+
</button>
+
</div>
+
</div>
+
+
<!-- Sections Tab -->
+
<div style="margin-bottom: 1.5rem;">
+
<h3 style="margin-bottom: 1rem; color: var(--text);">Manage Sections</h3>
+
+
<div style="display: flex; gap: 0.75rem; margin-bottom: 1rem;">
+
<input
+
type="text"
+
placeholder="Section number (e.g., 01, 02, A, B)"
+
.value=${this.newSectionNumber}
+
@input=${(e: Event) => {
+
this.newSectionNumber = (e.target as HTMLInputElement).value;
+
}}
+
@keypress=${(e: KeyboardEvent) => {
+
if (e.key === "Enter") {
+
e.preventDefault();
+
this.handleAddSection();
+
}
+
}}
+
style="flex: 1; padding: 0.75rem; border: 2px solid var(--secondary); border-radius: 6px; font-size: 1rem; background: var(--background); color: var(--text);"
+
/>
+
<button
+
@click=${this.handleAddSection}
+
?disabled=${!this.newSectionNumber.trim()}
+
style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer; white-space: nowrap;"
+
>
+
Add Section
+
</button>
+
</div>
+
+
${
+
this.editingClassSections.length === 0
+
? html`<p style="color: var(--paynes-gray); text-align: center; padding: 2rem;">No sections yet. Add one above.</p>`
+
: html`
+
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
+
${this.editingClassSections.map(
+
(section) => html`
+
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: color-mix(in srgb, var(--secondary) 30%, transparent); border-radius: 6px;">
+
<span style="font-weight: 500;">Section ${section.section_number}</span>
+
<button
+
@click=${(e: Event) => {
+
e.stopPropagation();
+
this.handleDeleteSection(section.id);
+
}}
+
style="padding: 0.5rem 1rem; background: transparent; color: red; border: 2px solid red; border-radius: 4px; font-size: 0.875rem; cursor: pointer;"
+
>
+
Delete
+
</button>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
}
+
</div>
+
+
<!-- Actions -->
+
<div style="display: flex; gap: 0.75rem; justify-content: space-between; padding-top: 1.5rem; border-top: 2px solid var(--secondary);">
+
<div style="display: flex; gap: 0.75rem;">
+
<button
+
@click=${(e: Event) => {
+
e.stopPropagation();
+
this.handleToggleArchive(this.editingClassId!);
+
this.handleCloseSectionsModal();
+
}}
+
style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"
+
>
+
${this.editingClassInfo.archived ? "Unarchive" : "Archive"} Class
+
</button>
+
<button
+
@click=${(e: Event) => {
+
e.stopPropagation();
+
this.handleDeleteClick(this.editingClassId!, "class");
+
}}
+
style="padding: 0.75rem 1.5rem; background: transparent; color: red; border: 2px solid red; border-radius: 6px; font-weight: 500; cursor: pointer;"
+
>
+
${this.getDeleteButtonText(this.editingClassId!, "class")}
+
</button>
+
</div>
+
<button
+
@click=${this.handleCloseSectionsModal}
+
style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"
+
>
+
Done
+
</button>
+
</div>
+
+
${this.error ? html`<div style="color: red; margin-top: 1rem; padding: 0.75rem; background: color-mix(in srgb, red 10%, transparent); border-radius: 6px;">${this.error}</div>` : ""}
+
</div>
+
</div>
+
`;
+
}
+
private renderWaitlist() {
return html`
${
···
this.meetingTimes = e.detail;
}
+
private handleSectionsChange(e: Event) {
+
const value = (e.target as HTMLInputElement).value;
+
this.sections = value
+
.split(",")
+
.map((s) => s.trim())
+
.filter((s) => s);
+
}
+
private handleClassFieldInput(field: string, e: Event) {
const value = (e.target as HTMLInputElement | HTMLSelectElement).value;
this.editingClass = { ...this.editingClass, [field]: value };
···
this.showModal = false;
this.approvingEntry = null;
this.meetingTimes = [];
+
this.sections = [];
this.editingClass = {
courseCode: "",
courseName: "",
···
semester: this.editingClass.semester,
year: this.editingClass.year,
meeting_times: labels,
+
sections: this.sections.length > 0 ? this.sections : undefined,
}),
});
···
.value=${this.meetingTimes}
@change=${this.handleMeetingTimesChange}
></meeting-time-picker>
+
</div>
+
<div class="form-group form-group-full">
+
<label>Sections (optional)</label>
+
<input
+
type="text"
+
placeholder="e.g., 01, 02, 03 or A, B, C"
+
.value=${this.sections.join(", ")}
+
@input=${this.handleSectionsChange}
+
/>
+
<div class="help-text">Comma-separated list of section numbers. Leave blank if no sections.</div>
</div>
</div>
+60 -11
src/components/class-registration-modal.ts
···
professor: string;
semester: string;
year: number;
is_enrolled?: boolean;
}
···
@state() error = "";
@state() hasSearched = false;
@state() showWaitlistForm = false;
@state() waitlistData = {
courseCode: "",
courseName: "",
···
this.error = "";
this.hasSearched = false;
this.showWaitlistForm = false;
this.waitlistData = {
courseCode: "",
courseName: "",
···
}
}
-
private async handleJoin(classId: string) {
this.isJoining = true;
this.error = "";
···
const response = await fetch("/api/classes/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ class_id: classId }),
});
if (!response.ok) {
const data = await response.json();
this.error = data.error || "Failed to join class";
return;
}
// Success - notify parent and close
this.dispatchEvent(new CustomEvent("class-joined"));
this.handleClose();
-
} catch {
this.error = "Failed to join class. Please try again.";
-
} finally {
this.isJoining = false;
}
}
···
<div class="results-grid">
${this.results.map(
(cls) => html`
-
<button
-
class="class-card ${cls.is_enrolled ? "enrolled" : ""}"
-
@click=${() => !cls.is_enrolled && this.handleJoin(cls.id)}
-
?disabled=${this.isJoining || cls.is_enrolled}
-
>
<div class="class-header">
<div class="class-info">
<div class="course-code">
···
<span>👤 ${cls.professor}</span>
<span>📅 ${cls.semester} ${cls.year}</span>
</div>
</div>
${
!cls.is_enrolled
···
?disabled=${this.isJoining}
@click=${(e: Event) => {
e.stopPropagation();
-
this.handleJoin(cls.id);
}}
>
${this.isJoining ? "Joining..." : "Join"}
···
: ""
}
</div>
-
</button>
`,
)}
</div>
···
professor: string;
semester: string;
year: number;
+
sections?: { id: string; section_number: string }[];
is_enrolled?: boolean;
}
···
@state() error = "";
@state() hasSearched = false;
@state() showWaitlistForm = false;
+
@state() selectedSections: Map<string, string> = new Map();
@state() waitlistData = {
courseCode: "",
courseName: "",
···
this.error = "";
this.hasSearched = false;
this.showWaitlistForm = false;
+
this.selectedSections = new Map();
this.waitlistData = {
courseCode: "",
courseName: "",
···
}
}
+
private async handleJoin(
+
classId: string,
+
sections?: { id: string; section_number: string }[],
+
) {
+
// If class has sections, require section selection
+
const selectedSection = this.selectedSections.get(classId);
+
if (sections && sections.length > 0 && !selectedSection) {
+
this.error = "Please select a section";
+
this.requestUpdate();
+
return;
+
}
+
this.isJoining = true;
this.error = "";
···
const response = await fetch("/api/classes/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
class_id: classId,
+
section_id: selectedSection || null,
+
}),
});
if (!response.ok) {
const data = await response.json();
this.error = data.error || "Failed to join class";
+
this.isJoining = false;
+
this.requestUpdate();
return;
}
// Success - notify parent and close
this.dispatchEvent(new CustomEvent("class-joined"));
this.handleClose();
+
} catch (error) {
+
console.error("Failed to join class:", error);
this.error = "Failed to join class. Please try again.";
this.isJoining = false;
+
this.requestUpdate();
}
}
···
<div class="results-grid">
${this.results.map(
(cls) => html`
+
<div class="class-card ${cls.is_enrolled ? "enrolled" : ""}">
<div class="class-header">
<div class="class-info">
<div class="course-code">
···
<span>👤 ${cls.professor}</span>
<span>📅 ${cls.semester} ${cls.year}</span>
</div>
+
${
+
!cls.is_enrolled &&
+
cls.sections &&
+
cls.sections.length > 0
+
? html`
+
<div style="margin-top: 0.75rem;">
+
<label style="font-size: 0.75rem; margin-bottom: 0.25rem;">Select Section *</label>
+
<select
+
style="width: 100%; padding: 0.5rem; border: 2px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; background: var(--background); color: var(--text);"
+
@change=${(e: Event) => {
+
const sectionId = (
+
e.target as HTMLSelectElement
+
).value;
+
if (sectionId) {
+
this.selectedSections.set(cls.id, sectionId);
+
} else {
+
this.selectedSections.delete(cls.id);
+
}
+
this.error = "";
+
this.requestUpdate();
+
}}
+
>
+
<option value="">Choose a section...</option>
+
${cls.sections.map(
+
(s) =>
+
html`<option value="${s.id}" ?selected=${this.selectedSections.get(cls.id) === s.id}>${s.section_number}</option>`,
+
)}
+
</select>
+
</div>
+
`
+
: ""
+
}
</div>
${
!cls.is_enrolled
···
?disabled=${this.isJoining}
@click=${(e: Event) => {
e.stopPropagation();
+
console.log('Join button clicked for class:', cls.id, 'sections:', cls.sections);
+
this.handleJoin(cls.id, cls.sections);
}}
>
${this.isJoining ? "Joining..." : "Join"}
···
: ""
}
</div>
+
</div>
`,
)}
</div>
+112 -6
src/components/class-view.ts
···
import { customElement, state } from "lit/decorators.js";
import "./upload-recording-modal.ts";
import "./vtt-viewer.ts";
interface Class {
id: string;
···
id: string;
user_id: number;
meeting_time_id: string | null;
filename: string;
original_filename: string;
status:
···
audioUrl?: string;
}
@customElement("class-view")
export class ClassView extends LitElement {
@state() classId = "";
@state() classInfo: Class | null = null;
@state() meetingTimes: MeetingTime[] = [];
@state() transcriptions: Transcription[] = [];
@state() isLoading = true;
@state() error: string | null = null;
···
const data = await response.json();
this.classInfo = data.class;
this.meetingTimes = data.meetingTimes || [];
this.transcriptions = data.transcriptions || [];
// Load VTT for completed transcriptions
await this.loadVTTForCompleted();
···
}
private get filteredTranscriptions() {
-
if (!this.searchQuery) return this.transcriptions;
-
const query = this.searchQuery.toLowerCase();
-
return this.transcriptions.filter((t) =>
-
t.original_filename.toLowerCase().includes(query),
-
);
}
private formatDate(timestamp: number): string {
···
<div class="course-code">${this.classInfo.course_code}</div>
<h1>${this.classInfo.name}</h1>
<div class="professor">Professor: ${this.classInfo.professor}</div>
-
<div class="semester">${this.classInfo.semester} ${this.classInfo.year}</div>
</div>
</div>
···
`
: html`
<div class="search-upload">
<input
type="text"
class="search-box"
···
</button>
</div>
${
this.filteredTranscriptions.length === 0
? html`
···
?open=${this.uploadModalOpen}
.classId=${this.classId}
.meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))}
@close=${this.handleModalClose}
@upload-success=${this.handleUploadSuccess}
></upload-recording-modal>
···
import { customElement, state } from "lit/decorators.js";
import "./upload-recording-modal.ts";
import "./vtt-viewer.ts";
+
import "./pending-recordings-view.ts";
interface Class {
id: string;
···
id: string;
user_id: number;
meeting_time_id: string | null;
+
section_id: string | null;
filename: string;
original_filename: string;
status:
···
audioUrl?: string;
}
+
interface ClassSection {
+
id: string;
+
section_number: string;
+
}
+
@customElement("class-view")
export class ClassView extends LitElement {
@state() classId = "";
@state() classInfo: Class | null = null;
@state() meetingTimes: MeetingTime[] = [];
+
@state() sections: ClassSection[] = [];
+
@state() userSection: string | null = null;
+
@state() selectedSectionFilter: string | null = null;
@state() transcriptions: Transcription[] = [];
@state() isLoading = true;
@state() error: string | null = null;
···
const data = await response.json();
this.classInfo = data.class;
this.meetingTimes = data.meetingTimes || [];
+
this.sections = data.sections || [];
+
this.userSection = data.userSection || null;
this.transcriptions = data.transcriptions || [];
+
+
// Default to user's section for filtering
+
if (this.userSection && !this.selectedSectionFilter) {
+
this.selectedSectionFilter = this.userSection;
+
}
// Load VTT for completed transcriptions
await this.loadVTTForCompleted();
···
}
private get filteredTranscriptions() {
+
let filtered = this.transcriptions;
+
+
// Filter by selected section (or user's section by default)
+
const sectionFilter = this.selectedSectionFilter || this.userSection;
+
+
// Only filter by section if:
+
// 1. There are sections in the class
+
// 2. User has a section OR has selected one
+
if (this.sections.length > 0 && sectionFilter) {
+
// For admins: show all transcriptions
+
// For users: show their section + transcriptions with no section (legacy/unassigned)
+
if (!this.isAdmin) {
+
filtered = filtered.filter(
+
(t) => t.section_id === sectionFilter || t.section_id === null,
+
);
+
}
+
}
+
+
// Filter by search query
+
if (this.searchQuery) {
+
const query = this.searchQuery.toLowerCase();
+
filtered = filtered.filter((t) =>
+
t.original_filename.toLowerCase().includes(query),
+
);
+
}
+
// Exclude pending recordings (they're shown in the voting section)
+
filtered = filtered.filter((t) => t.status !== "pending");
+
+
return filtered;
}
private formatDate(timestamp: number): string {
···
<div class="course-code">${this.classInfo.course_code}</div>
<h1>${this.classInfo.name}</h1>
<div class="professor">Professor: ${this.classInfo.professor}</div>
+
<div class="semester">
+
${this.classInfo.semester} ${this.classInfo.year}
+
${
+
this.userSection
+
? ` • Section ${this.sections.find((s) => s.id === this.userSection)?.section_number || ""}`
+
: ""
+
}
+
</div>
</div>
</div>
···
`
: html`
<div class="search-upload">
+
${
+
this.sections.length > 1
+
? html`
+
<select
+
style="padding: 0.5rem 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"
+
@change=${(e: Event) => {
+
this.selectedSectionFilter =
+
(e.target as HTMLSelectElement).value || null;
+
}}
+
.value=${this.selectedSectionFilter || ""}
+
>
+
${this.sections.map(
+
(s) =>
+
html`<option value=${s.id} ?selected=${s.id === this.selectedSectionFilter}>${s.section_number}</option>`,
+
)}
+
</select>
+
`
+
: ""
+
}
<input
type="text"
class="search-box"
···
</button>
</div>
+
<!-- Pending Recordings for Voting -->
+
${
+
this.meetingTimes.map((meeting) => {
+
// Apply section filtering to pending recordings
+
const sectionFilter = this.selectedSectionFilter || this.userSection;
+
+
const pendingCount = this.transcriptions.filter((t) => {
+
if (t.meeting_time_id !== meeting.id || t.status !== "pending") {
+
return false;
+
}
+
+
// Filter by section if applicable
+
if (this.sections.length > 0 && sectionFilter) {
+
// Show recordings from user's section or no section (unassigned)
+
return t.section_id === sectionFilter || t.section_id === null;
+
}
+
+
return true;
+
}).length;
+
+
// Only show if there are pending recordings
+
if (pendingCount === 0) return "";
+
+
return html`
+
<div style="margin-bottom: 2rem;">
+
<pending-recordings-view
+
.classId=${this.classId}
+
.meetingTimeId=${meeting.id}
+
.meetingTimeLabel=${meeting.label}
+
.sectionId=${sectionFilter}
+
></pending-recordings-view>
+
</div>
+
`;
+
})
+
}
+
+
<!-- Completed/Processing Transcriptions -->
${
this.filteredTranscriptions.length === 0
? html`
···
?open=${this.uploadModalOpen}
.classId=${this.classId}
.meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))}
+
.sections=${this.sections}
+
.userSection=${this.userSection}
@close=${this.handleModalClose}
@upload-success=${this.handleUploadSuccess}
></upload-recording-modal>
+1
src/components/classes-overview.ts
···
}
private async handleClassJoined() {
await this.loadClasses();
}
···
}
private async handleClassJoined() {
+
this.showRegistrationModal = false;
await this.loadClasses();
}
+436
src/components/pending-recordings-view.ts
···
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, property, state } from "lit/decorators.js";
+
+
interface PendingRecording {
+
id: string;
+
user_id: number;
+
filename: string;
+
original_filename: string;
+
vote_count: number;
+
created_at: number;
+
}
+
+
interface RecordingsData {
+
recordings: PendingRecording[];
+
total_users: number;
+
user_vote: string | null;
+
vote_threshold: number;
+
winning_recording_id: string | null;
+
}
+
+
@customElement("pending-recordings-view")
+
export class PendingRecordingsView extends LitElement {
+
@property({ type: String }) classId = "";
+
@property({ type: String }) meetingTimeId = "";
+
@property({ type: String }) meetingTimeLabel = "";
+
@property({ type: String }) sectionId: string | null = null;
+
+
@state() private recordings: PendingRecording[] = [];
+
@state() private userVote: string | null = null;
+
@state() private voteThreshold = 0;
+
@state() private winningRecordingId: string | null = null;
+
@state() private error: string | null = null;
+
@state() private timeRemaining = "";
+
+
private refreshInterval?: number;
+
private loadingInProgress = false;
+
+
static override styles = css`
+
:host {
+
display: block;
+
padding: 1rem;
+
}
+
+
.container {
+
max-width: 56rem;
+
margin: 0 auto;
+
}
+
+
h2 {
+
color: var(--text);
+
margin-bottom: 0.5rem;
+
}
+
+
.info {
+
color: var(--paynes-gray);
+
font-size: 0.875rem;
+
margin-bottom: 1.5rem;
+
}
+
+
.stats {
+
display: flex;
+
gap: 2rem;
+
margin-bottom: 1.5rem;
+
padding: 1rem;
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
border-radius: 8px;
+
}
+
+
.stat {
+
display: flex;
+
flex-direction: column;
+
gap: 0.25rem;
+
}
+
+
.stat-label {
+
font-size: 0.75rem;
+
color: var(--paynes-gray);
+
text-transform: uppercase;
+
letter-spacing: 0.05em;
+
}
+
+
.stat-value {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
}
+
+
.recordings-list {
+
display: flex;
+
flex-direction: column;
+
gap: 1rem;
+
}
+
+
.recording-card {
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1rem;
+
transition: all 0.2s;
+
}
+
+
.recording-card.voted {
+
border-color: var(--accent);
+
background: color-mix(in srgb, var(--accent) 5%, transparent);
+
}
+
+
.recording-card.winning {
+
border-color: var(--accent);
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
+
}
+
+
.recording-header {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
margin-bottom: 0.75rem;
+
}
+
+
.recording-info {
+
flex: 1;
+
}
+
+
.recording-name {
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 0.25rem;
+
}
+
+
.recording-meta {
+
font-size: 0.75rem;
+
color: var(--paynes-gray);
+
}
+
+
.vote-section {
+
display: flex;
+
align-items: center;
+
gap: 1rem;
+
}
+
+
.vote-count {
+
font-size: 1.25rem;
+
font-weight: 600;
+
color: var(--accent);
+
min-width: 3rem;
+
text-align: center;
+
}
+
+
.vote-button {
+
padding: 0.5rem 1rem;
+
border-radius: 6px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
border: 2px solid var(--secondary);
+
background: var(--background);
+
color: var(--text);
+
}
+
+
.vote-button:hover:not(:disabled) {
+
border-color: var(--accent);
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
+
}
+
+
.vote-button.voted {
+
border-color: var(--accent);
+
background: var(--accent);
+
color: var(--white);
+
}
+
+
.vote-button:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.delete-button {
+
padding: 0.5rem;
+
border: none;
+
background: transparent;
+
color: var(--paynes-gray);
+
cursor: pointer;
+
border-radius: 4px;
+
transition: all 0.2s;
+
}
+
+
.delete-button:hover {
+
background: color-mix(in srgb, red 10%, transparent);
+
color: red;
+
}
+
+
.winning-badge {
+
background: var(--accent);
+
color: var(--white);
+
padding: 0.25rem 0.75rem;
+
border-radius: 12px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
}
+
+
.error {
+
background: color-mix(in srgb, red 10%, transparent);
+
border: 1px solid red;
+
color: red;
+
padding: 0.75rem;
+
border-radius: 4px;
+
margin-bottom: 1rem;
+
font-size: 0.875rem;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 3rem 1rem;
+
color: var(--paynes-gray);
+
}
+
+
.audio-player {
+
margin-top: 0.75rem;
+
}
+
+
audio {
+
width: 100%;
+
height: 2.5rem;
+
}
+
`;
+
+
override connectedCallback() {
+
super.connectedCallback();
+
this.loadRecordings();
+
// Refresh every 10 seconds
+
this.refreshInterval = setInterval(() => this.loadRecordings(), 10000);
+
}
+
+
override disconnectedCallback() {
+
super.disconnectedCallback();
+
if (this.refreshInterval) {
+
clearInterval(this.refreshInterval);
+
}
+
}
+
+
private async loadRecordings() {
+
if (this.loadingInProgress) return;
+
+
this.loadingInProgress = true;
+
+
try {
+
// Build URL with optional section_id parameter
+
const url = new URL(
+
`/api/classes/${this.classId}/meetings/${this.meetingTimeId}/recordings`,
+
window.location.origin,
+
);
+
if (this.sectionId !== null) {
+
url.searchParams.set("section_id", this.sectionId);
+
}
+
+
const response = await fetch(url.toString());
+
+
if (!response.ok) {
+
throw new Error("Failed to load recordings");
+
}
+
+
const data: RecordingsData = await response.json();
+
this.recordings = data.recordings;
+
this.userVote = data.user_vote;
+
this.voteThreshold = data.vote_threshold;
+
this.winningRecordingId = data.winning_recording_id;
+
+
// Calculate time remaining for first recording
+
if (this.recordings.length > 0 && this.recordings[0]) {
+
const uploadedAt = this.recordings[0].created_at;
+
const now = Date.now() / 1000;
+
const elapsed = now - uploadedAt;
+
const remaining = 30 * 60 - elapsed; // 30 minutes
+
+
if (remaining > 0) {
+
const minutes = Math.floor(remaining / 60);
+
const seconds = Math.floor(remaining % 60);
+
this.timeRemaining = `${minutes}:${seconds.toString().padStart(2, "0")}`;
+
} else {
+
this.timeRemaining = "Auto-submitting...";
+
}
+
}
+
+
this.error = null;
+
} catch (error) {
+
this.error =
+
error instanceof Error ? error.message : "Failed to load recordings";
+
} finally {
+
this.loadingInProgress = false;
+
}
+
}
+
+
private async handleVote(recordingId: string) {
+
try {
+
const response = await fetch(`/api/recordings/${recordingId}/vote`, {
+
method: "POST",
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to vote");
+
}
+
+
const data = await response.json();
+
+
// If a winner was selected, reload the page to show it in transcriptions
+
if (data.winning_recording_id) {
+
window.location.reload();
+
} else {
+
// Just reload recordings to show updated votes
+
await this.loadRecordings();
+
}
+
} catch (error) {
+
this.error =
+
error instanceof Error ? error.message : "Failed to vote";
+
}
+
}
+
+
private async handleDelete(recordingId: string) {
+
if (!confirm("Delete this recording?")) {
+
return;
+
}
+
+
try {
+
const response = await fetch(`/api/recordings/${recordingId}`, {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to delete recording");
+
}
+
+
await this.loadRecordings();
+
} catch (error) {
+
this.error =
+
error instanceof Error ? error.message : "Failed to delete recording";
+
}
+
}
+
+
private formatTimeAgo(timestamp: number): string {
+
const now = Date.now() / 1000;
+
const diff = now - timestamp;
+
+
if (diff < 60) return "just now";
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+
return `${Math.floor(diff / 86400)}d ago`;
+
}
+
+
override render() {
+
return html`
+
<div class="container">
+
<h2>Pending Recordings - ${this.meetingTimeLabel}</h2>
+
<p class="info">
+
Vote for the best quality recording. The winner will be automatically transcribed when 40% of class votes or after 30 minutes.
+
</p>
+
+
${this.error ? html`<div class="error">${this.error}</div>` : ""}
+
+
${
+
this.recordings.length > 0
+
? html`
+
<div class="stats">
+
<div class="stat">
+
<div class="stat-label">Recordings</div>
+
<div class="stat-value">${this.recordings.length}</div>
+
</div>
+
<div class="stat">
+
<div class="stat-label">Vote Threshold</div>
+
<div class="stat-value">${this.voteThreshold} votes</div>
+
</div>
+
<div class="stat">
+
<div class="stat-label">Time Remaining</div>
+
<div class="stat-value">${this.timeRemaining}</div>
+
</div>
+
</div>
+
+
<div class="recordings-list">
+
${this.recordings.map(
+
(recording) => html`
+
<div class="recording-card ${this.userVote === recording.id ? "voted" : ""} ${this.winningRecordingId === recording.id ? "winning" : ""}">
+
<div class="recording-header">
+
<div class="recording-info">
+
<div class="recording-name">${recording.original_filename}</div>
+
<div class="recording-meta">
+
Uploaded ${this.formatTimeAgo(recording.created_at)}
+
</div>
+
</div>
+
+
<div class="vote-section">
+
${
+
this.winningRecordingId === recording.id
+
? html`<span class="winning-badge">✨ Selected</span>`
+
: ""
+
}
+
+
<div class="vote-count">
+
${recording.vote_count} ${recording.vote_count === 1 ? "vote" : "votes"}
+
</div>
+
+
<button
+
class="vote-button ${this.userVote === recording.id ? "voted" : ""}"
+
@click=${() => this.handleVote(recording.id)}
+
?disabled=${this.winningRecordingId !== null}
+
>
+
${this.userVote === recording.id ? "✓ Voted" : "Vote"}
+
</button>
+
+
<button
+
class="delete-button"
+
@click=${() => this.handleDelete(recording.id)}
+
title="Delete recording"
+
>
+
🗑️
+
</button>
+
</div>
+
</div>
+
+
<div class="audio-player">
+
<audio controls preload="none">
+
<source src="/api/transcriptions/${recording.id}/audio" type="audio/mpeg">
+
</audio>
+
</div>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
: html`
+
<div class="empty-state">
+
<p>No recordings uploaded yet for this meeting time.</p>
+
<p>Upload a recording to get started!</p>
+
</div>
+
`
+
}
+
</div>
+
`;
+
}
+
}
+360 -66
src/components/upload-recording-modal.ts
···
label: string;
}
@customElement("upload-recording-modal")
export class UploadRecordingModal extends LitElement {
@property({ type: Boolean }) open = false;
@property({ type: String }) classId = "";
@property({ type: Array }) meetingTimes: MeetingTime[] = [];
@state() private selectedFile: File | null = null;
@state() private selectedMeetingTimeId: string | null = null;
@state() private uploading = false;
@state() private error: string | null = null;
static override styles = css`
:host {
···
align-items: center;
gap: 0.5rem;
}
`;
-
private handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0] ?? null;
this.error = null;
}
}
-
private handleMeetingTimeChange(e: Event) {
-
const select = e.target as HTMLSelectElement;
-
this.selectedMeetingTimeId = select.value || null;
-
}
-
private handleClose() {
-
if (this.uploading) return;
-
this.open = false;
-
this.selectedFile = null;
-
this.selectedMeetingTimeId = null;
-
this.error = null;
-
this.dispatchEvent(new CustomEvent("close"));
-
}
-
private async handleUpload() {
-
if (!this.selectedFile) {
-
this.error = "Please select a file to upload";
-
return;
-
}
-
if (!this.selectedMeetingTimeId) {
-
this.error = "Please select a meeting time";
-
return;
}
-
this.uploading = true;
-
this.error = null;
try {
const formData = new FormData();
-
formData.append("audio", this.selectedFile);
formData.append("class_id", this.classId);
-
formData.append("meeting_time_id", this.selectedMeetingTimeId);
-
const response = await fetch("/api/transcriptions", {
method: "POST",
body: formData,
});
if (!response.ok) {
const data = await response.json();
-
throw new Error(data.error || "Upload failed");
}
-
// Success - close modal and notify parent
this.dispatchEvent(new CustomEvent("upload-success"));
this.handleClose();
} catch (error) {
-
console.error("Upload failed:", error);
-
this.error =
-
error instanceof Error
-
? error.message
-
: "Upload failed. Please try again.";
-
} finally {
-
this.uploading = false;
}
}
override render() {
if (!this.open) return null;
···
<div class="help-text">Maximum file size: 100MB</div>
</div>
-
<div class="form-group">
-
<label for="meeting-time">Meeting Time</label>
-
<select
-
id="meeting-time"
-
@change=${this.handleMeetingTimeChange}
-
?disabled=${this.uploading}
-
required
-
>
-
<option value="">Select a meeting time...</option>
-
${this.meetingTimes.map(
-
(meeting) => html`
-
<option value=${meeting.id}>${meeting.label}</option>
-
`,
-
)}
-
</select>
-
<div class="help-text">
-
Select which meeting this recording is for
-
</div>
-
</div>
</form>
<div class="modal-footer">
-
<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading}>
Cancel
</button>
-
<button
-
class="btn-upload"
-
@click=${this.handleUpload}
-
?disabled=${this.uploading || !this.selectedFile || !this.selectedMeetingTimeId}
-
>
-
${
-
this.uploading
-
? html`<span class="uploading-text">Uploading...</span>`
-
: "Upload"
-
}
-
</button>
</div>
</div>
</div>
···
label: string;
}
+
interface ClassSection {
+
id: string;
+
section_number: string;
+
}
+
@customElement("upload-recording-modal")
export class UploadRecordingModal extends LitElement {
@property({ type: Boolean }) open = false;
@property({ type: String }) classId = "";
@property({ type: Array }) meetingTimes: MeetingTime[] = [];
+
@property({ type: Array }) sections: ClassSection[] = [];
+
@property({ type: String }) userSection: string | null = null;
@state() private selectedFile: File | null = null;
@state() private selectedMeetingTimeId: string | null = null;
+
@state() private selectedSectionId: string | null = null;
@state() private uploading = false;
+
@state() private uploadProgress = 0;
@state() private error: string | null = null;
+
@state() private detectedMeetingTime: string | null = null;
+
@state() private detectingMeetingTime = false;
+
@state() private uploadComplete = false;
+
@state() private uploadedTranscriptionId: string | null = null;
+
@state() private submitting = false;
+
@state() private selectedDate: string = "";
static override styles = css`
:host {
···
align-items: center;
gap: 0.5rem;
}
+
+
.meeting-time-selector {
+
display: flex;
+
gap: 0.5rem;
+
}
+
+
.meeting-time-button {
+
padding: 0.75rem 1rem;
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
color: var(--text);
+
text-align: left;
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.meeting-time-button:hover {
+
border-color: var(--primary);
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
}
+
+
.meeting-time-button.selected {
+
background: var(--primary);
+
border-color: var(--primary);
+
color: white;
+
}
+
+
.meeting-time-button.detected {
+
border-color: var(--accent);
+
}
+
+
.meeting-time-button.detected::after {
+
content: "✨ Auto-detected";
+
margin-left: auto;
+
font-size: 0.75rem;
+
opacity: 0.8;
+
}
+
+
.detecting-text {
+
font-size: 0.875rem;
+
color: var(--paynes-gray);
+
padding: 0.5rem;
+
text-align: center;
+
font-style: italic;
+
}
`;
+
private async handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0] ?? null;
this.error = null;
+
this.detectedMeetingTime = null;
+
this.selectedMeetingTimeId = null;
+
this.uploadComplete = false;
+
this.uploadedTranscriptionId = null;
+
this.submitting = false;
+
this.selectedDate = "";
+
+
if (this.selectedFile && this.classId) {
+
// Set initial date from file
+
const fileDate = new Date(this.selectedFile.lastModified);
+
this.selectedDate = fileDate.toISOString().split("T")[0] || "";
+
// Start both detection and upload in parallel
+
this.detectMeetingTime();
+
this.startBackgroundUpload();
+
}
}
}
+
private async startBackgroundUpload() {
+
if (!this.selectedFile) return;
+
+
this.uploading = true;
+
this.uploadProgress = 0;
+
+
try {
+
const formData = new FormData();
+
formData.append("audio", this.selectedFile);
+
formData.append("class_id", this.classId);
+
+
// Send recording date (from date picker or file timestamp)
+
if (this.selectedDate) {
+
// Convert YYYY-MM-DD to timestamp (noon local time)
+
const date = new Date(`${this.selectedDate}T12:00:00`);
+
formData.append("recording_date", Math.floor(date.getTime() / 1000).toString());
+
} else if (this.selectedFile.lastModified) {
+
// Use file's lastModified as recording date
+
formData.append("recording_date", Math.floor(this.selectedFile.lastModified / 1000).toString());
+
}
+
+
// Don't send section_id yet - will be set via PATCH when user confirms
+
+
const xhr = new XMLHttpRequest();
+
// Track upload progress
+
xhr.upload.addEventListener("progress", (e) => {
+
if (e.lengthComputable) {
+
this.uploadProgress = Math.round((e.loaded / e.total) * 100);
+
}
+
});
+
+
// Handle completion
+
xhr.addEventListener("load", () => {
+
if (xhr.status >= 200 && xhr.status < 300) {
+
this.uploadComplete = true;
+
this.uploading = false;
+
const response = JSON.parse(xhr.responseText);
+
this.uploadedTranscriptionId = response.id;
+
} else {
+
this.uploading = false;
+
const response = JSON.parse(xhr.responseText);
+
this.error = response.error || "Upload failed";
+
}
+
});
+
// Handle errors
+
xhr.addEventListener("error", () => {
+
this.uploading = false;
+
this.error = "Upload failed. Please try again.";
+
});
+
xhr.open("POST", "/api/transcriptions");
+
xhr.send(formData);
+
} catch (error) {
+
console.error("Upload failed:", error);
+
this.uploading = false;
+
this.error =
+
error instanceof Error
+
? error.message
+
: "Upload failed. Please try again.";
}
+
}
+
private async detectMeetingTime() {
+
if (!this.classId) return;
+
+
this.detectingMeetingTime = true;
try {
const formData = new FormData();
formData.append("class_id", this.classId);
+
+
// Use selected date or file's lastModified timestamp
+
let timestamp: number;
+
if (this.selectedDate) {
+
// Convert YYYY-MM-DD to timestamp (noon local time to avoid timezone issues)
+
const date = new Date(`${this.selectedDate}T12:00:00`);
+
timestamp = date.getTime();
+
} else if (this.selectedFile?.lastModified) {
+
timestamp = this.selectedFile.lastModified;
+
} else {
+
return;
+
}
+
formData.append("file_timestamp", timestamp.toString());
+
+
const response = await fetch("/api/transcriptions/detect-meeting-time", {
method: "POST",
body: formData,
});
if (!response.ok) {
+
console.warn("Failed to detect meeting time");
+
return;
+
}
+
+
const data = await response.json();
+
+
if (data.detected && data.meeting_time_id) {
+
this.detectedMeetingTime = data.meeting_time_id;
+
this.selectedMeetingTimeId = data.meeting_time_id;
+
}
+
} catch (error) {
+
console.warn("Error detecting meeting time:", error);
+
} finally {
+
this.detectingMeetingTime = false;
+
}
+
}
+
+
private handleMeetingTimeSelect(meetingTimeId: string) {
+
this.selectedMeetingTimeId = meetingTimeId;
+
}
+
+
private handleDateChange(e: Event) {
+
const input = e.target as HTMLInputElement;
+
this.selectedDate = input.value;
+
// Re-detect meeting time when date changes
+
if (this.selectedDate && this.classId) {
+
this.detectMeetingTime();
+
}
+
}
+
+
private handleSectionChange(e: Event) {
+
const select = e.target as HTMLSelectElement;
+
this.selectedSectionId = select.value || null;
+
}
+
+
private async handleSubmit() {
+
if (!this.uploadedTranscriptionId || !this.selectedMeetingTimeId) return;
+
+
this.submitting = true;
+
this.error = null;
+
+
try {
+
// Get section to use (selected override or user's section)
+
const sectionToUse = this.selectedSectionId || this.userSection;
+
+
const response = await fetch(
+
`/api/transcriptions/${this.uploadedTranscriptionId}/meeting-time`,
+
{
+
method: "PATCH",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
meeting_time_id: this.selectedMeetingTimeId,
+
section_id: sectionToUse,
+
}),
+
},
+
);
+
+
if (!response.ok) {
const data = await response.json();
+
this.error = data.error || "Failed to update meeting time";
+
this.submitting = false;
+
return;
}
+
// Success - close modal and refresh
this.dispatchEvent(new CustomEvent("upload-success"));
this.handleClose();
} catch (error) {
+
console.error("Failed to update meeting time:", error);
+
this.error = "Failed to update meeting time";
+
this.submitting = false;
}
}
+
private handleClose() {
+
if (this.uploading || this.submitting) return;
+
this.open = false;
+
this.selectedFile = null;
+
this.selectedMeetingTimeId = null;
+
this.selectedSectionId = null;
+
this.error = null;
+
this.detectedMeetingTime = null;
+
this.detectingMeetingTime = false;
+
this.uploadComplete = false;
+
this.uploadProgress = 0;
+
this.uploadedTranscriptionId = null;
+
this.submitting = false;
+
this.selectedDate = "";
+
this.dispatchEvent(new CustomEvent("close"));
+
}
+
override render() {
if (!this.open) return null;
···
<div class="help-text">Maximum file size: 100MB</div>
</div>
+
${
+
this.selectedFile
+
? html`
+
<div class="form-group">
+
<label for="date">Recording Date</label>
+
<input
+
type="date"
+
id="date"
+
.value=${this.selectedDate}
+
@change=${this.handleDateChange}
+
?disabled=${this.uploading}
+
style="padding: 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"
+
/>
+
<div class="help-text">
+
Change the date to detect the correct meeting time
+
</div>
+
</div>
+
+
<div class="form-group">
+
<label>Meeting Time</label>
+
${
+
this.detectingMeetingTime
+
? html`<div class="detecting-text">Detecting meeting time from audio metadata...</div>`
+
: html`
+
<div class="meeting-time-selector">
+
${this.meetingTimes.map(
+
(meeting) => html`
+
<button
+
type="button"
+
class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}"
+
@click=${() => this.handleMeetingTimeSelect(meeting.id)}
+
?disabled=${this.uploading}
+
>
+
${meeting.label}
+
</button>
+
`,
+
)}
+
</div>
+
`
+
}
+
<div class="help-text">
+
${
+
this.detectedMeetingTime
+
? "Auto-detected based on recording date. You can change if needed."
+
: "Select which meeting this recording is for"
+
}
+
</div>
+
</div>
+
`
+
: ""
+
}
+
+
${
+
this.sections.length > 0 && this.selectedFile
+
? html`
+
<div class="form-group">
+
<label for="section">Section</label>
+
<select
+
id="section"
+
@change=${this.handleSectionChange}
+
?disabled=${this.uploading}
+
.value=${this.selectedSectionId || this.userSection || ""}
+
>
+
<option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option>
+
${this.sections
+
.filter((section) => section.id !== this.userSection)
+
.map(
+
(section) => html`
+
<option value=${section.id}>${section.section_number}</option>
+
`,
+
)}
+
</select>
+
<div class="help-text">
+
Select which section this recording is for (defaults to your section)
+
</div>
+
</div>
+
`
+
: ""
+
}
+
+
${
+
this.uploading || this.uploadComplete
+
? html`
+
<div class="form-group">
+
<label>Upload Status</label>
+
<div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;">
+
${
+
this.uploadComplete
+
? html`
+
<div style="color: green; font-weight: 500;">
+
✓ Upload complete! Select a meeting time to continue.
+
</div>
+
`
+
: html`
+
<div style="color: var(--text); font-weight: 500; margin-bottom: 0.5rem;">
+
Uploading... ${this.uploadProgress}%
+
</div>
+
<div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;">
+
<div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div>
+
</div>
+
`
+
}
+
</div>
+
</div>
+
`
+
: ""
+
}
</form>
<div class="modal-footer">
+
<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}>
Cancel
</button>
+
${
+
this.uploadComplete && this.selectedMeetingTimeId
+
? html`
+
<button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}>
+
${this.submitting ? "Submitting..." : "Confirm & Submit"}
+
</button>
+
`
+
: ""
+
}
</div>
</div>
</div>
+70 -1
src/db/schema.ts
···
import { Database } from "bun:sqlite";
// Use test database when NODE_ENV is test
-
const dbPath = process.env.NODE_ENV === "test" ? "thistle.test.db" : "thistle.db";
export const db = new Database(dbPath);
console.log(`[Database] Using database: ${dbPath}`);
···
INSERT OR IGNORE INTO users (id, email, password_hash, name, avatar, role, created_at)
VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now'));
`,
},
];
···
import { Database } from "bun:sqlite";
// Use test database when NODE_ENV is test
+
const dbPath =
+
process.env.NODE_ENV === "test" ? "thistle.test.db" : "thistle.db";
export const db = new Database(dbPath);
console.log(`[Database] Using database: ${dbPath}`);
···
INSERT OR IGNORE INTO users (id, email, password_hash, name, avatar, role, created_at)
VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now'));
`,
+
},
+
{
+
version: 2,
+
name: "Add sections support to classes and class members",
+
sql: `
+
-- Add section_number to classes (nullable for existing classes)
+
ALTER TABLE classes ADD COLUMN section_number TEXT;
+
+
-- Add section_id to class_members (nullable - NULL means default section)
+
ALTER TABLE class_members ADD COLUMN section_id TEXT;
+
+
-- Create sections table to track all available sections for a class
+
CREATE TABLE IF NOT EXISTS class_sections (
+
id TEXT PRIMARY KEY,
+
class_id TEXT NOT NULL,
+
section_number TEXT NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
UNIQUE(class_id, section_number)
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_class_sections_class_id ON class_sections(class_id);
+
+
-- Add section_id to transcriptions to track which section uploaded it
+
ALTER TABLE transcriptions ADD COLUMN section_id TEXT;
+
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id);
+
`,
+
},
+
{
+
version: 3,
+
name: "Add voting system for collaborative recording selection",
+
sql: `
+
-- Add vote count to transcriptions
+
ALTER TABLE transcriptions ADD COLUMN vote_count INTEGER NOT NULL DEFAULT 0;
+
+
-- Add auto-submitted flag to track if transcription was auto-selected
+
ALTER TABLE transcriptions ADD COLUMN auto_submitted BOOLEAN DEFAULT 0;
+
+
-- Create votes table to track who voted for which recording
+
CREATE TABLE IF NOT EXISTS recording_votes (
+
id TEXT PRIMARY KEY,
+
transcription_id TEXT NOT NULL,
+
user_id INTEGER NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (transcription_id) REFERENCES transcriptions(id) ON DELETE CASCADE,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+
UNIQUE(transcription_id, user_id)
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_recording_votes_transcription_id ON recording_votes(transcription_id);
+
CREATE INDEX IF NOT EXISTS idx_recording_votes_user_id ON recording_votes(user_id);
+
`,
+
},
+
{
+
version: 4,
+
name: "Add recording_date to transcriptions for chronological ordering",
+
sql: `
+
-- Add recording_date (timestamp when the recording was made, not uploaded)
+
-- Defaults to created_at for existing records
+
ALTER TABLE transcriptions ADD COLUMN recording_date INTEGER;
+
+
-- Set recording_date to created_at for existing records
+
UPDATE transcriptions SET recording_date = created_at WHERE recording_date IS NULL;
+
+
-- Create index for ordering by recording date
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_recording_date ON transcriptions(recording_date);
+
`,
},
];
+82 -86
src/index.test.ts
···
expect,
test,
} from "bun:test";
-
import { hashPasswordClient } from "./lib/client-auth";
import type { Subprocess } from "bun";
// Test server configuration
const TEST_PORT = 3001;
···
const stdoutReader = serverProcess.stdout.getReader();
const stderrReader = serverProcess.stderr.getReader();
const decoder = new TextDecoder();
-
(async () => {
try {
while (true) {
···
}
} catch {}
})();
-
(async () => {
try {
while (true) {
···
// Clear database between each test
beforeEach(async () => {
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
-
// Delete all data from tables (preserve schema)
db.run("DELETE FROM rate_limit_attempts");
db.run("DELETE FROM email_change_tokens");
···
db.run("DELETE FROM classes");
db.run("DELETE FROM class_waitlist");
db.run("DELETE FROM users WHERE id != 0"); // Keep ghost user
-
db.close();
});
···
}
// Helper to register a user, verify email, and get session via login
-
async function registerAndLogin(user: { email: string; password: string; name?: string }): Promise<string> {
const hashedPassword = await clientHashPassword(user.email, user.password);
// Register the user
···
}),
});
-
if (registerResponse.status !== 200) {
const error = await registerResponse.json();
throw new Error(`Registration failed: ${JSON.stringify(error)}`);
}
···
// Helper to add active subscription to a user
function addSubscription(userEmail: string): void {
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
-
const user = db.query("SELECT id FROM users WHERE email = ?").get(userEmail) as { id: number };
if (!user) {
db.close();
throw new Error(`User ${userEmail} not found`);
}
-
db.run(
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
-
[`test-sub-${user.id}`, user.id, `test-customer-${user.id}`, "active"]
);
db.close();
}
···
}),
});
-
if (response.status !== 200) {
const error = await response.json();
console.error("Registration failed:", response.status, error);
}
-
expect(response.status).toBe(200);
-
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
···
expect(data.error).toBe("Email and password required");
});
-
test(
-
"should reject registration with invalid password format",
-
async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: "short",
-
}),
-
});
-
expect(response.status).toBe(400);
-
const data = await response.json();
-
expect(data.error).toBe("Invalid password format");
-
},
-
);
test("should reject duplicate email registration", async () => {
const hashedPassword = await clientHashPassword(
···
}),
});
-
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Email already registered");
});
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify user is deleted
const verifyResponse = await authRequest(
···
// Manually complete the email change in the database (simulating verification)
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
-
const tokenData = db.query("SELECT user_id, new_email FROM email_change_tokens ORDER BY created_at DESC LIMIT 1").get() as { user_id: number, new_email: string };
-
db.run("UPDATE users SET email = ?, email_verified = 1 WHERE id = ?", [tokenData.new_email, tokenData.user_id]);
-
db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [tokenData.user_id]);
db.close();
// Verify email updated
···
},
);
-
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
···
);
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify name updated
const meResponse = await authRequest(
···
describe("API Endpoints - Health", () => {
describe("GET /api/health", () => {
-
test(
-
"should return service health status with details",
-
async () => {
-
const response = await fetch(`${BASE_URL}/api/health`);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data).toHaveProperty("status");
-
expect(data).toHaveProperty("timestamp");
-
expect(data).toHaveProperty("services");
-
expect(data.services).toHaveProperty("database");
-
expect(data.services).toHaveProperty("whisper");
-
expect(data.services).toHaveProperty("storage");
-
},
-
);
});
});
···
test("should return user transcriptions", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
-
// Add subscription
addSubscription(TEST_USER.email);
···
test("should upload audio file and start transcription", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
-
// Add subscription
addSubscription(TEST_USER.email);
···
},
);
-
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBeDefined();
expect(data.message).toContain("Upload successful");
···
test("should reject non-audio files", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
-
// Add subscription
addSubscription(TEST_USER.email);
···
test("should reject files exceeding size limit", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
-
// Add subscription
addSubscription(TEST_USER.email);
···
beforeEach(async () => {
// Create admin user
adminCookie = await registerAndLogin(TEST_ADMIN);
-
// Manually set admin role in database
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
···
.query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
.get(TEST_USER.email);
userId = userIdResult?.id;
-
db.close();
});
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify user is deleted
const verifyResponse = await authRequest(
···
);
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify role updated
const meResponse = await authRequest(
···
},
);
-
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify sessions are deleted
const verifyResponse = await authRequest(
···
});
describe("POST /api/passkeys/register/options", () => {
-
test(
-
"should return registration options for authenticated user",
-
async () => {
-
const response = await authRequest(
-
`${BASE_URL}/api/passkeys/register/options`,
-
sessionCookie,
-
{
-
method: "POST",
-
},
-
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data).toHaveProperty("challenge");
-
expect(data).toHaveProperty("rp");
-
expect(data).toHaveProperty("user");
-
},
-
);
test("should require authentication", async () => {
const response = await fetch(
···
expect,
test,
} from "bun:test";
import type { Subprocess } from "bun";
+
import { hashPasswordClient } from "./lib/client-auth";
// Test server configuration
const TEST_PORT = 3001;
···
const stdoutReader = serverProcess.stdout.getReader();
const stderrReader = serverProcess.stderr.getReader();
const decoder = new TextDecoder();
+
(async () => {
try {
while (true) {
···
}
} catch {}
})();
+
(async () => {
try {
while (true) {
···
// Clear database between each test
beforeEach(async () => {
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
// Delete all data from tables (preserve schema)
db.run("DELETE FROM rate_limit_attempts");
db.run("DELETE FROM email_change_tokens");
···
db.run("DELETE FROM classes");
db.run("DELETE FROM class_waitlist");
db.run("DELETE FROM users WHERE id != 0"); // Keep ghost user
+
db.close();
});
···
}
// Helper to register a user, verify email, and get session via login
+
async function registerAndLogin(user: {
+
email: string;
+
password: string;
+
name?: string;
+
}): Promise<string> {
const hashedPassword = await clientHashPassword(user.email, user.password);
// Register the user
···
}),
});
+
if (registerResponse.status !== 201) {
const error = await registerResponse.json();
throw new Error(`Registration failed: ${JSON.stringify(error)}`);
}
···
// Helper to add active subscription to a user
function addSubscription(userEmail: string): void {
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
const user = db
+
.query("SELECT id FROM users WHERE email = ?")
+
.get(userEmail) as { id: number };
if (!user) {
db.close();
throw new Error(`User ${userEmail} not found`);
}
+
db.run(
"INSERT INTO subscriptions (id, user_id, customer_id, status) VALUES (?, ?, ?, ?)",
+
[`test-sub-${user.id}`, user.id, `test-customer-${user.id}`, "active"],
);
db.close();
}
···
}),
});
+
if (response.status !== 201) {
const error = await response.json();
console.error("Registration failed:", response.status, error);
}
+
expect(response.status).toBe(201);
+
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
···
expect(data.error).toBe("Email and password required");
});
+
test("should reject registration with invalid password format", async () => {
+
const response = await fetch(`${BASE_URL}/api/auth/register`, {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email: TEST_USER.email,
+
password: "short",
+
}),
+
});
+
expect(response.status).toBe(400);
+
const data = await response.json();
+
expect(data.error).toBe("Invalid password format");
+
});
test("should reject duplicate email registration", async () => {
const hashedPassword = await clientHashPassword(
···
}),
});
+
expect(response.status).toBe(409);
const data = await response.json();
expect(data.error).toBe("Email already registered");
});
···
},
);
+
expect(response.status).toBe(204);
// Verify user is deleted
const verifyResponse = await authRequest(
···
// Manually complete the email change in the database (simulating verification)
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
const tokenData = db
+
.query(
+
"SELECT user_id, new_email FROM email_change_tokens ORDER BY created_at DESC LIMIT 1",
+
)
+
.get() as { user_id: number; new_email: string };
+
db.run("UPDATE users SET email = ?, email_verified = 1 WHERE id = ?", [
+
tokenData.new_email,
+
tokenData.user_id,
+
]);
+
db.run("DELETE FROM email_change_tokens WHERE user_id = ?", [
+
tokenData.user_id,
+
]);
db.close();
// Verify email updated
···
},
);
+
expect(response.status).toBe(409);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
···
);
expect(response.status).toBe(200);
// Verify name updated
const meResponse = await authRequest(
···
describe("API Endpoints - Health", () => {
describe("GET /api/health", () => {
+
test("should return service health status with details", async () => {
+
const response = await fetch(`${BASE_URL}/api/health`);
+
expect(response.status).toBe(200);
+
const data = await response.json();
+
expect(data).toHaveProperty("status");
+
expect(data).toHaveProperty("timestamp");
+
expect(data).toHaveProperty("services");
+
expect(data.services).toHaveProperty("database");
+
expect(data.services).toHaveProperty("whisper");
+
expect(data.services).toHaveProperty("storage");
+
});
});
});
···
test("should return user transcriptions", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
+
// Add subscription
addSubscription(TEST_USER.email);
···
test("should upload audio file and start transcription", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
+
// Add subscription
addSubscription(TEST_USER.email);
···
},
);
+
expect(response.status).toBe(201);
const data = await response.json();
expect(data.id).toBeDefined();
expect(data.message).toContain("Upload successful");
···
test("should reject non-audio files", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
+
// Add subscription
addSubscription(TEST_USER.email);
···
test("should reject files exceeding size limit", async () => {
// Register and login
const sessionCookie = await registerAndLogin(TEST_USER);
+
// Add subscription
addSubscription(TEST_USER.email);
···
beforeEach(async () => {
// Create admin user
adminCookie = await registerAndLogin(TEST_ADMIN);
+
// Manually set admin role in database
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
db.run("UPDATE users SET role = 'admin' WHERE email = ?", [
···
.query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
.get(TEST_USER.email);
userId = userIdResult?.id;
+
db.close();
});
···
},
);
+
expect(response.status).toBe(204);
// Verify user is deleted
const verifyResponse = await authRequest(
···
);
expect(response.status).toBe(200);
// Verify role updated
const meResponse = await authRequest(
···
},
);
+
expect(response.status).toBe(409);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
···
},
);
+
expect(response.status).toBe(204);
// Verify sessions are deleted
const verifyResponse = await authRequest(
···
});
describe("POST /api/passkeys/register/options", () => {
+
test("should return registration options for authenticated user", async () => {
+
const response = await authRequest(
+
`${BASE_URL}/api/passkeys/register/options`,
+
sessionCookie,
+
{
+
method: "POST",
+
},
+
);
+
expect(response.status).toBe(200);
+
const data = await response.json();
+
expect(data).toHaveProperty("challenge");
+
expect(data).toHaveProperty("rp");
+
expect(data).toHaveProperty("user");
+
});
test("should require authentication", async () => {
const response = await fetch(
+518 -75
src/index.ts
···
getClassById,
getClassesForUser,
getClassMembers,
getMeetingById,
getMeetingTimesForClass,
getTranscriptionsForClass,
isUserEnrolledInClass,
joinClass,
removeUserFromClass,
searchClassesByCourseCode,
toggleClassArchive,
updateMeetingTime,
} from "./lib/classes";
import { sendEmail } from "./lib/email";
import {
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
import {
validateClassId,
validateCourseCode,
···
email: string,
): Promise<void> {
// Skip Polar sync in test mode
-
if (process.env.NODE_ENV === "test" || process.env.SKIP_POLAR_SYNC === "true") {
return;
}
···
);
const server = Bun.serve({
-
port: process.env.NODE_ENV === "test"
-
? 3001
-
: (process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000),
idleTimeout: 120, // 120 seconds for SSE connections
routes: {
"/": indexHTML,
···
email_verification_required: true,
verification_code_sent_at: sentAt,
},
-
{ status: 200 },
);
} catch (err: unknown) {
const error = err as { message?: string };
if (error.message?.includes("UNIQUE constraint failed")) {
return Response.json(
{ error: "Email already registered" },
-
{ status: 400 },
);
}
console.error("[Auth] Registration error:", err);
···
role: user.role,
has_subscription: !!subscription,
email_verified: isEmailVerified(user.id),
-
email_notifications_enabled: prefs?.email_notifications_enabled === 1,
});
} catch (err) {
return handleError(err);
···
}
updatePasskeyName(passkeyId, user.id, name);
-
return Response.json({ success: true });
} catch (err) {
return handleError(err);
}
···
const passkeyId = req.params.id;
deletePasskey(passkeyId, user.id);
-
return Response.json({ success: true });
} catch (err) {
return handleError(err);
}
···
try {
const sessionId = getSessionFromRequest(req);
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(sessionId);
if (!user) {
···
try {
const currentSessionId = getSessionFromRequest(req);
if (!currentSessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(currentSessionId);
if (!user) {
···
// Verify the session belongs to the user
const targetSession = getSession(targetSessionId);
if (!targetSession || targetSession.user_id !== user.id) {
-
return Response.json({ error: "Session not found" }, { status: 404 });
}
deleteSession(targetSessionId);
-
return Response.json({ success: true });
} catch (err) {
return handleError(err);
}
···
if (rateLimitError) return rateLimitError;
await deleteUser(user.id);
-
return Response.json(
-
{ success: true },
-
{
-
headers: {
-
"Set-Cookie":
-
"session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
-
},
},
-
);
} catch (err) {
return handleError(err);
}
···
if (existingUser) {
return Response.json(
{ error: "Email already in use" },
-
{ status: 400 },
);
}
···
const body = await req.json();
const { password } = body;
if (!password) {
-
return Response.json({ error: "Password required" }, { status: 400 });
}
// Validate password format (client-side hashed PBKDF2)
const passwordValidation = validatePasswordHash(password);
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
}
// Require subscription only if accessing own transcription (not class)
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
}
// Require subscription only if accessing own transcription (not class)
···
if (transcription.status !== "completed") {
return Response.json(
{ error: "Transcription not completed yet" },
-
{ status: 400 },
);
}
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
}
// Require subscription only if accessing own transcription (not class)
···
}
},
},
"/api/transcriptions": {
GET: async (req) => {
try {
···
const formData = await req.formData();
const file = formData.get("audio") as File;
const classId = formData.get("class_id") as string | null;
-
const meetingTimeId = formData.get("meeting_time_id") as
| string
| null;
···
const uploadDir = "./uploads";
await Bun.write(`${uploadDir}/${filename}`, file);
-
// Create database record
db.run(
-
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
transcriptionId,
user.id,
classId,
-
meetingTimeId,
filename,
file.name,
"pending",
],
);
// Don't auto-start transcription - admin will select recordings
// whisperService.startTranscription(transcriptionId, filename);
-
return Response.json({
-
id: transcriptionId,
-
message: "Upload successful",
-
});
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const id = req.params.id;
deleteWaitlistEntry(id);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const transcriptionId = req.params.id;
deleteTranscription(transcriptionId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
await deleteUser(userId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
const { passkeyId } = req.params;
deletePasskey(passkeyId, userId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
if (existing) {
return Response.json(
{ error: "Email already in use" },
-
{ status: 400 },
);
}
···
}
deleteAllUserSessions(userId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
);
}
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
cursor,
);
-
// For admin, return flat array. For users, group by semester/year
-
if (user.role === "admin") {
-
return Response.json(result.data);
-
}
-
-
// Group by semester/year for regular users
const grouped: Record<
string,
Array<{
···
semester,
year,
meeting_times,
});
-
return Response.json(newClass);
} catch (error) {
return handleError(error);
}
···
.all(user.id)
.map((row) => row.class_id);
-
// Add is_enrolled flag to each class
const classesWithEnrollment = classes.map((cls) => ({
...cls,
is_enrolled: enrolledClassIds.includes(cls.id),
}));
return Response.json({ classes: classesWithEnrollment });
···
const user = requireAuth(req);
const body = await req.json();
const classId = body.class_id;
const classIdValidation = validateClassId(classId);
if (!classIdValidation.valid) {
···
);
}
-
const result = joinClass(classId, user.id);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
meetingTimes || null,
);
-
return Response.json({ success: true, id });
} catch (error) {
return handleError(error);
}
···
}
const meetingTimes = getMeetingTimesForClass(classId);
const transcriptions = getTranscriptionsForClass(classId);
return Response.json({
class: classInfo,
meetingTimes,
transcriptions,
});
} catch (error) {
···
}
deleteClass(classId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
}
toggleClassArchive(classId, archived);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
}
enrollUserInClass(user.id, classId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
}
removeUserFromClass(userId, classId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
}
const meetingTime = createMeetingTime(classId, label);
-
return Response.json(meetingTime);
} catch (error) {
return handleError(error);
}
···
// Verify meeting exists
const existingMeeting = getMeetingById(meetingId);
if (!existingMeeting) {
-
return Response.json({ error: "Meeting not found" }, { status: 404 });
}
updateMeetingTime(meetingId, label);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
// Verify meeting exists
const existingMeeting = getMeetingById(meetingId);
if (!existingMeeting) {
-
return Response.json({ error: "Meeting not found" }, { status: 404 });
}
deleteMeetingTime(meetingId);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
transcription.filename,
);
-
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
},
},
},
-
development: {
-
hmr: true,
-
console: true,
},
});
console.log(`🪻 Thistle running at http://localhost:${server.port}`);
···
getClassById,
getClassesForUser,
getClassMembers,
+
getClassSections,
getMeetingById,
getMeetingTimesForClass,
getTranscriptionsForClass,
+
getUserSection,
isUserEnrolledInClass,
joinClass,
removeUserFromClass,
searchClassesByCourseCode,
toggleClassArchive,
updateMeetingTime,
+
createClassSection,
} from "./lib/classes";
import { sendEmail } from "./lib/email";
import {
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
+
import {
+
findMatchingMeetingTime,
+
getDayName,
+
} from "./lib/audio-metadata";
+
import {
+
checkAutoSubmit,
+
deletePendingRecording,
+
getEnrolledUserCount,
+
getPendingRecordings,
+
getUserVoteForMeeting,
+
markAsAutoSubmitted,
+
removeVote,
+
voteForRecording,
+
} from "./lib/voting";
import {
validateClassId,
validateCourseCode,
···
email: string,
): Promise<void> {
// Skip Polar sync in test mode
+
if (
+
process.env.NODE_ENV === "test" ||
+
process.env.SKIP_POLAR_SYNC === "true"
+
) {
return;
}
···
);
const server = Bun.serve({
+
port:
+
process.env.NODE_ENV === "test"
+
? 3001
+
: process.env.PORT
+
? Number.parseInt(process.env.PORT, 10)
+
: 3000,
idleTimeout: 120, // 120 seconds for SSE connections
routes: {
"/": indexHTML,
···
email_verification_required: true,
verification_code_sent_at: sentAt,
},
+
{ status: 201 },
);
} catch (err: unknown) {
const error = err as { message?: string };
if (error.message?.includes("UNIQUE constraint failed")) {
return Response.json(
{ error: "Email already registered" },
+
{ status: 409 },
);
}
console.error("[Auth] Registration error:", err);
···
role: user.role,
has_subscription: !!subscription,
email_verified: isEmailVerified(user.id),
+
email_notifications_enabled:
+
prefs?.email_notifications_enabled === 1,
});
} catch (err) {
return handleError(err);
···
}
updatePasskeyName(passkeyId, user.id, name);
+
return new Response(null, { status: 204 });
} catch (err) {
return handleError(err);
}
···
const passkeyId = req.params.id;
deletePasskey(passkeyId, user.id);
+
return new Response(null, { status: 204 });
} catch (err) {
return handleError(err);
}
···
try {
const sessionId = getSessionFromRequest(req);
if (!sessionId) {
+
return Response.json(
+
{ error: "Not authenticated" },
+
{ status: 401 },
+
);
}
const user = getUserBySession(sessionId);
if (!user) {
···
try {
const currentSessionId = getSessionFromRequest(req);
if (!currentSessionId) {
+
return Response.json(
+
{ error: "Not authenticated" },
+
{ status: 401 },
+
);
}
const user = getUserBySession(currentSessionId);
if (!user) {
···
// Verify the session belongs to the user
const targetSession = getSession(targetSessionId);
if (!targetSession || targetSession.user_id !== user.id) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
}
deleteSession(targetSessionId);
+
return new Response(null, { status: 204 });
} catch (err) {
return handleError(err);
}
···
if (rateLimitError) return rateLimitError;
await deleteUser(user.id);
+
return new Response(null, {
+
status: 204,
+
headers: {
+
"Set-Cookie":
+
"session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
},
+
});
} catch (err) {
return handleError(err);
}
···
if (existingUser) {
return Response.json(
{ error: "Email already in use" },
+
{ status: 409 },
);
}
···
const body = await req.json();
const { password } = body;
if (!password) {
+
return Response.json(
+
{ error: "Password required" },
+
{ status: 400 },
+
);
}
// Validate password format (client-side hashed PBKDF2)
const passwordValidation = validatePasswordHash(password);
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Require subscription only if accessing own transcription (not class)
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Require subscription only if accessing own transcription (not class)
···
if (transcription.status !== "completed") {
return Response.json(
{ error: "Transcription not completed yet" },
+
{ status: 409 },
);
}
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
}
// Require subscription only if accessing own transcription (not class)
···
}
},
},
+
"/api/transcriptions/detect-meeting-time": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
+
const formData = await req.formData();
+
const file = formData.get("audio") as File;
+
const classId = formData.get("class_id") as string | null;
+
const fileTimestampStr = formData.get("file_timestamp") as
+
| string
+
| null;
+
+
if (!file) throw ValidationErrors.missingField("audio");
+
if (!classId) throw ValidationErrors.missingField("class_id");
+
+
// Verify user is enrolled in the class
+
const enrolled = isUserEnrolledInClass(user.id, classId);
+
if (!enrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
let creationDate: Date | null = null;
+
+
// Use client-provided timestamp (from File.lastModified)
+
if (fileTimestampStr) {
+
const timestamp = Number.parseInt(fileTimestampStr, 10);
+
if (!Number.isNaN(timestamp)) {
+
creationDate = new Date(timestamp);
+
console.log(
+
`[Upload] Using file timestamp: ${creationDate.toISOString()}`,
+
);
+
}
+
}
+
+
if (!creationDate) {
+
return Response.json({
+
detected: false,
+
meeting_time_id: null,
+
message: "Could not extract creation date from file",
+
});
+
}
+
+
// Get meeting times for this class
+
const meetingTimes = getMeetingTimesForClass(classId);
+
+
if (meetingTimes.length === 0) {
+
return Response.json({
+
detected: false,
+
meeting_time_id: null,
+
message: "No meeting times configured for this class",
+
});
+
}
+
+
// Find matching meeting time based on day of week
+
const matchedId = findMatchingMeetingTime(
+
creationDate,
+
meetingTimes,
+
);
+
+
if (matchedId) {
+
const dayName = getDayName(creationDate);
+
return Response.json({
+
detected: true,
+
meeting_time_id: matchedId,
+
day: dayName,
+
date: creationDate.toISOString(),
+
});
+
}
+
+
const dayName = getDayName(creationDate);
+
return Response.json({
+
detected: false,
+
meeting_time_id: null,
+
day: dayName,
+
date: creationDate.toISOString(),
+
message: `No meeting time matches ${dayName}`,
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/transcriptions/:id/meeting-time": {
+
PATCH: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const transcriptionId = req.params.id;
+
+
const body = await req.json();
+
const meetingTimeId = body.meeting_time_id;
+
const sectionId = body.section_id;
+
+
if (!meetingTimeId) {
+
return Response.json(
+
{ error: "meeting_time_id required" },
+
{ status: 400 },
+
);
+
}
+
+
// Verify transcription ownership
+
const transcription = db
+
.query<
+
{ id: string; user_id: number; class_id: string | null },
+
[string]
+
>("SELECT id, user_id, class_id FROM transcriptions WHERE id = ?")
+
.get(transcriptionId);
+
+
if (!transcription) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
+
if (transcription.user_id !== user.id && user.role !== "admin") {
+
return Response.json({ error: "Forbidden" }, { status: 403 });
+
}
+
+
// Verify meeting time belongs to the class
+
if (transcription.class_id) {
+
const meetingTime = db
+
.query<{ id: string }, [string, string]>(
+
"SELECT id FROM meeting_times WHERE id = ? AND class_id = ?",
+
)
+
.get(meetingTimeId, transcription.class_id);
+
+
if (!meetingTime) {
+
return Response.json(
+
{
+
error:
+
"Meeting time does not belong to the class for this transcription",
+
},
+
{ status: 400 },
+
);
+
}
+
}
+
+
// Update meeting time and optionally section_id
+
if (sectionId !== undefined) {
+
db.run(
+
"UPDATE transcriptions SET meeting_time_id = ?, section_id = ? WHERE id = ?",
+
[meetingTimeId, sectionId, transcriptionId],
+
);
+
} else {
+
db.run(
+
"UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?",
+
[meetingTimeId, transcriptionId],
+
);
+
}
+
+
return Response.json({
+
success: true,
+
message: "Meeting time updated successfully",
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:classId/meetings/:meetingTimeId/recordings": {
+
GET: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const classId = req.params.classId;
+
const meetingTimeId = req.params.meetingTimeId;
+
+
// Verify user is enrolled in the class
+
const enrolled = isUserEnrolledInClass(user.id, classId);
+
if (!enrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
// Get section filter from query params or use user's section
+
const url = new URL(req.url);
+
const sectionParam = url.searchParams.get("section_id");
+
const sectionFilter =
+
sectionParam !== null
+
? sectionParam || null // empty string becomes null
+
: user.role === "admin"
+
? null
+
: getUserSection(user.id, classId);
+
+
const recordings = getPendingRecordings(
+
classId,
+
meetingTimeId,
+
sectionFilter,
+
);
+
const totalUsers = getEnrolledUserCount(classId);
+
const userVote = getUserVoteForMeeting(
+
user.id,
+
classId,
+
meetingTimeId,
+
);
+
+
// Check if any recording should be auto-submitted
+
const winningId = checkAutoSubmit(
+
classId,
+
meetingTimeId,
+
sectionFilter,
+
);
+
+
return Response.json({
+
recordings,
+
total_users: totalUsers,
+
user_vote: userVote,
+
vote_threshold: Math.ceil(totalUsers * 0.4),
+
winning_recording_id: winningId,
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/recordings/:id/vote": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const recordingId = req.params.id;
+
+
// Verify user is enrolled in the recording's class
+
const recording = db
+
.query<
+
{ class_id: string; meeting_time_id: string; status: string },
+
[string]
+
>(
+
"SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?",
+
)
+
.get(recordingId);
+
+
if (!recording) {
+
return Response.json(
+
{ error: "Recording not found" },
+
{ status: 404 },
+
);
+
}
+
+
if (recording.status !== "pending") {
+
return Response.json(
+
{ error: "Can only vote on pending recordings" },
+
{ status: 400 },
+
);
+
}
+
+
const enrolled = isUserEnrolledInClass(user.id, recording.class_id);
+
if (!enrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
// Remove existing vote for this meeting time
+
const existingVote = getUserVoteForMeeting(
+
user.id,
+
recording.class_id,
+
recording.meeting_time_id,
+
);
+
if (existingVote) {
+
removeVote(existingVote, user.id);
+
}
+
+
// Add new vote
+
const success = voteForRecording(recordingId, user.id);
+
+
// Get user's section for auto-submit check
+
const userSection =
+
user.role === "admin"
+
? null
+
: getUserSection(user.id, recording.class_id);
+
+
// Check if auto-submit threshold reached
+
const winningId = checkAutoSubmit(
+
recording.class_id,
+
recording.meeting_time_id,
+
userSection,
+
);
+
if (winningId) {
+
markAsAutoSubmitted(winningId);
+
// Start transcription
+
const winningRecording = db
+
.query<{ filename: string }, [string]>(
+
"SELECT filename FROM transcriptions WHERE id = ?",
+
)
+
.get(winningId);
+
if (winningRecording) {
+
whisperService.startTranscription(
+
winningId,
+
winningRecording.filename,
+
);
+
}
+
}
+
+
return Response.json({
+
success,
+
winning_recording_id: winningId,
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/recordings/:id": {
+
DELETE: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const recordingId = req.params.id;
+
+
const success = deletePendingRecording(
+
recordingId,
+
user.id,
+
user.role === "admin",
+
);
+
+
if (!success) {
+
return Response.json(
+
{ error: "Cannot delete this recording" },
+
{ status: 403 },
+
);
+
}
+
+
return new Response(null, { status: 204 });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
"/api/transcriptions": {
GET: async (req) => {
try {
···
const formData = await req.formData();
const file = formData.get("audio") as File;
const classId = formData.get("class_id") as string | null;
+
const sectionId = formData.get("section_id") as string | null;
+
const recordingDateStr = formData.get("recording_date") as
| string
| null;
···
const uploadDir = "./uploads";
await Bun.write(`${uploadDir}/${filename}`, file);
+
// Parse recording date (default to current time if not provided)
+
const recordingDate = recordingDateStr
+
? Number.parseInt(recordingDateStr, 10)
+
: Math.floor(Date.now() / 1000);
+
+
// Create database record (without meeting_time_id - will be set later via PATCH)
db.run(
+
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
transcriptionId,
user.id,
classId,
+
null, // meeting_time_id will be set via PATCH endpoint
+
sectionId,
filename,
file.name,
"pending",
+
recordingDate,
],
);
// Don't auto-start transcription - admin will select recordings
// whisperService.startTranscription(transcriptionId, filename);
+
return Response.json(
+
{
+
id: transcriptionId,
+
message: "Upload successful",
+
},
+
{ status: 201 },
+
);
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const id = req.params.id;
deleteWaitlistEntry(id);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
requireAdmin(req);
const transcriptionId = req.params.id;
deleteTranscription(transcriptionId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
await deleteUser(userId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
const { passkeyId } = req.params;
deletePasskey(passkeyId, userId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
if (existing) {
return Response.json(
{ error: "Email already in use" },
+
{ status: 409 },
);
}
···
}
deleteAllUserSessions(userId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
);
}
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
cursor,
);
+
// Group by semester/year for all users
const grouped: Record<
string,
Array<{
···
semester,
year,
meeting_times,
+
sections: body.sections,
});
+
return Response.json(newClass, { status: 201 });
} catch (error) {
return handleError(error);
}
···
.all(user.id)
.map((row) => row.class_id);
+
// Add is_enrolled flag and sections to each class
const classesWithEnrollment = classes.map((cls) => ({
...cls,
is_enrolled: enrolledClassIds.includes(cls.id),
+
sections: getClassSections(cls.id),
}));
return Response.json({ classes: classesWithEnrollment });
···
const user = requireAuth(req);
const body = await req.json();
const classId = body.class_id;
+
const sectionId = body.section_id || null;
const classIdValidation = validateClassId(classId);
if (!classIdValidation.valid) {
···
);
}
+
const result = joinClass(classId, user.id, sectionId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
meetingTimes || null,
);
+
return Response.json({ success: true, id }, { status: 201 });
} catch (error) {
return handleError(error);
}
···
}
const meetingTimes = getMeetingTimesForClass(classId);
+
const sections = getClassSections(classId);
const transcriptions = getTranscriptionsForClass(classId);
+
const userSection = getUserSection(user.id, classId);
return Response.json({
class: classInfo,
meetingTimes,
+
sections,
+
userSection,
transcriptions,
});
} catch (error) {
···
}
deleteClass(classId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
}
toggleClassArchive(classId, archived);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
}
enrollUserInClass(user.id, classId);
+
return new Response(null, { status: 201 });
} catch (error) {
return handleError(error);
}
···
}
removeUserFromClass(userId, classId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
}
const meetingTime = createMeetingTime(classId, label);
+
return Response.json(meetingTime, { status: 201 });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:id/sections": {
+
POST: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
const body = await req.json();
+
const { section_number } = body;
+
+
if (!section_number) {
+
return Response.json({ error: "Section number required" }, { status: 400 });
+
}
+
+
const section = createClassSection(classId, section_number);
+
return Response.json(section);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:classId/sections/:sectionId": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const sectionId = req.params.sectionId;
+
+
// Check if any students are in this section
+
const studentsInSection = db
+
.query<{ count: number }, [string]>(
+
"SELECT COUNT(*) as count FROM class_members WHERE section_id = ?",
+
)
+
.get(sectionId);
+
+
if (studentsInSection && studentsInSection.count > 0) {
+
return Response.json(
+
{ error: "Cannot delete section with enrolled students" },
+
{ status: 400 },
+
);
+
}
+
+
db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
// Verify meeting exists
const existingMeeting = getMeetingById(meetingId);
if (!existingMeeting) {
+
return Response.json(
+
{ error: "Meeting not found" },
+
{ status: 404 },
+
);
}
updateMeetingTime(meetingId, label);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
// Verify meeting exists
const existingMeeting = getMeetingById(meetingId);
if (!existingMeeting) {
+
return Response.json(
+
{ error: "Meeting not found" },
+
{ status: 404 },
+
);
}
deleteMeetingTime(meetingId);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
···
transcription.filename,
);
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
}
},
},
},
+
development: process.env.NODE_ENV === "dev",
+
fetch(req, server) {
+
const response = server.fetch(req);
+
+
// Add security headers to all responses
+
if (response instanceof Response) {
+
const headers = new Headers(response.headers);
+
headers.set("Permissions-Policy", "interest-cohort=()");
+
headers.set("X-Content-Type-Options", "nosniff");
+
headers.set("X-Frame-Options", "DENY");
+
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
+
+
// Set CSP that allows inline styles with unsafe-inline (needed for Lit components)
+
// and script-src 'self' for bundled scripts
+
headers.set(
+
"Content-Security-Policy",
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://hostedboringavatars.vercel.app; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none';",
+
);
+
+
return new Response(response.body, {
+
status: response.status,
+
statusText: response.statusText,
+
headers,
+
});
+
}
+
+
return response;
},
});
console.log(`🪻 Thistle running at http://localhost:${server.port}`);
+55
src/lib/audio-metadata.integration.test.ts
···
···
+
import { afterAll, describe, expect, test } from "bun:test";
+
import { extractAudioCreationDate } from "./audio-metadata";
+
+
describe("extractAudioCreationDate (integration)", () => {
+
const testAudioPath = "./test-audio-sample.m4a";
+
+
// Clean up test file after tests
+
afterAll(async () => {
+
try {
+
await Bun.file(testAudioPath).exists().then(async (exists) => {
+
if (exists) {
+
await Bun.$`rm ${testAudioPath}`;
+
}
+
});
+
} catch {
+
// Ignore cleanup errors
+
}
+
});
+
+
test("extracts creation date from audio file with metadata", async () => {
+
// Create a test audio file with metadata using ffmpeg
+
// 1 second silent audio with creation_time metadata
+
const creationTime = "2024-01-15T14:30:00.000000Z";
+
+
// Create the file with metadata
+
await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -metadata creation_time=${creationTime} -y ${testAudioPath}`.quiet();
+
+
const date = await extractAudioCreationDate(testAudioPath);
+
+
expect(date).not.toBeNull();
+
expect(date).toBeInstanceOf(Date);
+
// JavaScript Date.toISOString() uses 3 decimal places, not 6 like the input
+
expect(date?.toISOString()).toBe("2024-01-15T14:30:00.000Z");
+
});
+
+
test("returns null for audio file without creation_time metadata", async () => {
+
// Create audio file without metadata
+
await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -y ${testAudioPath}`.quiet();
+
+
const date = await extractAudioCreationDate(testAudioPath);
+
+
// Should use file modification time as fallback
+
expect(date).not.toBeNull();
+
expect(date).toBeInstanceOf(Date);
+
// Should be very recent (within last minute)
+
const now = new Date();
+
const diff = now.getTime() - (date?.getTime() ?? 0);
+
expect(diff).toBeLessThan(60000); // Less than 1 minute
+
});
+
+
test("returns null for non-existent file", async () => {
+
const date = await extractAudioCreationDate("./non-existent-file.m4a");
+
expect(date).toBeNull();
+
});
+
});
+128
src/lib/audio-metadata.test.ts
···
···
+
import { describe, expect, test } from "bun:test";
+
import {
+
findMatchingMeetingTime,
+
getDayName,
+
getDayOfWeek,
+
meetingTimeLabelMatchesDay,
+
} from "./audio-metadata";
+
+
describe("getDayOfWeek", () => {
+
test("returns correct day number", () => {
+
// January 1, 2024 is a Monday (day 1)
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(getDayOfWeek(monday)).toBe(1);
+
+
// January 7, 2024 is a Sunday (day 0)
+
const sunday = new Date("2024-01-07T12:00:00Z");
+
expect(getDayOfWeek(sunday)).toBe(0);
+
+
// January 6, 2024 is a Saturday (day 6)
+
const saturday = new Date("2024-01-06T12:00:00Z");
+
expect(getDayOfWeek(saturday)).toBe(6);
+
});
+
});
+
+
describe("getDayName", () => {
+
test("returns correct day name", () => {
+
expect(getDayName(new Date("2024-01-01T12:00:00Z"))).toBe("Monday");
+
expect(getDayName(new Date("2024-01-02T12:00:00Z"))).toBe("Tuesday");
+
expect(getDayName(new Date("2024-01-03T12:00:00Z"))).toBe("Wednesday");
+
expect(getDayName(new Date("2024-01-04T12:00:00Z"))).toBe("Thursday");
+
expect(getDayName(new Date("2024-01-05T12:00:00Z"))).toBe("Friday");
+
expect(getDayName(new Date("2024-01-06T12:00:00Z"))).toBe("Saturday");
+
expect(getDayName(new Date("2024-01-07T12:00:00Z"))).toBe("Sunday");
+
});
+
});
+
+
describe("meetingTimeLabelMatchesDay", () => {
+
test("matches full day names", () => {
+
expect(meetingTimeLabelMatchesDay("Monday Lecture", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Tuesday Lab", "Tuesday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Wednesday Discussion", "Wednesday")).toBe(
+
true,
+
);
+
});
+
+
test("matches 3-letter abbreviations", () => {
+
expect(meetingTimeLabelMatchesDay("Mon Lecture", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Tue Lab", "Tuesday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Wed Discussion", "Wednesday")).toBe(
+
true,
+
);
+
expect(meetingTimeLabelMatchesDay("Thu Seminar", "Thursday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Fri Workshop", "Friday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Sat Review", "Saturday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("Sun Study", "Sunday")).toBe(true);
+
});
+
+
test("is case insensitive", () => {
+
expect(meetingTimeLabelMatchesDay("MONDAY LECTURE", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("monday lecture", "Monday")).toBe(true);
+
expect(meetingTimeLabelMatchesDay("MoNdAy LeCTuRe", "Monday")).toBe(true);
+
});
+
+
test("does not match wrong days", () => {
+
expect(meetingTimeLabelMatchesDay("Monday Lecture", "Tuesday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Wednesday Lab", "Thursday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Lecture Hall A", "Monday")).toBe(false);
+
});
+
+
test("handles labels without day names", () => {
+
expect(meetingTimeLabelMatchesDay("Lecture", "Monday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Lab Session", "Tuesday")).toBe(false);
+
expect(meetingTimeLabelMatchesDay("Section A", "Wednesday")).toBe(false);
+
});
+
});
+
+
describe("findMatchingMeetingTime", () => {
+
const meetingTimes = [
+
{ id: "mt1", label: "Monday Lecture" },
+
{ id: "mt2", label: "Wednesday Discussion" },
+
{ id: "mt3", label: "Friday Lab" },
+
];
+
+
test("finds correct meeting time for full day name", () => {
+
const monday = new Date("2024-01-01T12:00:00Z"); // Monday
+
expect(findMatchingMeetingTime(monday, meetingTimes)).toBe("mt1");
+
+
const wednesday = new Date("2024-01-03T12:00:00Z"); // Wednesday
+
expect(findMatchingMeetingTime(wednesday, meetingTimes)).toBe("mt2");
+
+
const friday = new Date("2024-01-05T12:00:00Z"); // Friday
+
expect(findMatchingMeetingTime(friday, meetingTimes)).toBe("mt3");
+
});
+
+
test("finds correct meeting time for abbreviated day name", () => {
+
const abbrevMeetingTimes = [
+
{ id: "mt1", label: "Mon Lecture" },
+
{ id: "mt2", label: "Wed Discussion" },
+
{ id: "mt3", label: "Fri Lab" },
+
];
+
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(findMatchingMeetingTime(monday, abbrevMeetingTimes)).toBe("mt1");
+
});
+
+
test("returns null when no match found", () => {
+
const tuesday = new Date("2024-01-02T12:00:00Z"); // Tuesday
+
expect(findMatchingMeetingTime(tuesday, meetingTimes)).toBe(null);
+
+
const saturday = new Date("2024-01-06T12:00:00Z"); // Saturday
+
expect(findMatchingMeetingTime(saturday, meetingTimes)).toBe(null);
+
});
+
+
test("returns null for empty meeting times", () => {
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(findMatchingMeetingTime(monday, [])).toBe(null);
+
});
+
+
test("returns first match when multiple matches exist", () => {
+
const duplicateMeetingTimes = [
+
{ id: "mt1", label: "Monday Lecture" },
+
{ id: "mt2", label: "Monday Lab" },
+
];
+
+
const monday = new Date("2024-01-01T12:00:00Z");
+
expect(findMatchingMeetingTime(monday, duplicateMeetingTimes)).toBe("mt1");
+
});
+
});
+144
src/lib/audio-metadata.ts
···
···
+
import { $ } from "bun";
+
+
/**
+
* Extracts creation date from audio file metadata using ffprobe
+
* Falls back to file birth time (original creation) if no metadata found
+
* @param filePath Path to audio file
+
* @returns Date object or null if not found
+
*/
+
export async function extractAudioCreationDate(
+
filePath: string,
+
): Promise<Date | null> {
+
try {
+
// Use ffprobe to extract creation_time metadata
+
// -v quiet: suppress verbose output
+
// -print_format json: output as JSON
+
// -show_entries format_tags: show all tags to search for date fields
+
const result =
+
await $`ffprobe -v quiet -print_format json -show_entries format_tags ${filePath}`.text();
+
+
const metadata = JSON.parse(result);
+
const tags = metadata?.format?.tags || {};
+
+
// Try multiple metadata fields that might contain creation date
+
const dateFields = [
+
tags.creation_time, // Standard creation_time
+
tags.date, // Common date field
+
tags.DATE, // Uppercase variant
+
tags.year, // Year field
+
tags.YEAR, // Uppercase variant
+
tags["com.apple.quicktime.creationdate"], // Apple QuickTime
+
tags.TDRC, // ID3v2 recording time
+
tags.TDRL, // ID3v2 release time
+
];
+
+
for (const dateField of dateFields) {
+
if (dateField) {
+
const date = new Date(dateField);
+
if (!Number.isNaN(date.getTime())) {
+
console.log(
+
`[AudioMetadata] Extracted creation date from metadata: ${date.toISOString()} from ${filePath}`,
+
);
+
return date;
+
}
+
}
+
}
+
+
// Fallback: use file birth time (original creation time on filesystem)
+
// This preserves the original file creation date better than mtime
+
console.log(
+
`[AudioMetadata] No creation_time metadata found, using file birth time`,
+
);
+
const file = Bun.file(filePath);
+
const stat = await file.stat();
+
const date = new Date(stat.birthtime || stat.mtime);
+
console.log(
+
`[AudioMetadata] Using file birth time: ${date.toISOString()} from ${filePath}`,
+
);
+
return date;
+
} catch (error) {
+
console.error(
+
`[AudioMetadata] Failed to extract metadata from ${filePath}:`,
+
error instanceof Error ? error.message : "Unknown error",
+
);
+
return null;
+
}
+
}
+
+
/**
+
* Gets day of week from a date (0 = Sunday, 6 = Saturday)
+
*/
+
export function getDayOfWeek(date: Date): number {
+
return date.getDay();
+
}
+
+
/**
+
* Gets day name from a date
+
*/
+
export function getDayName(date: Date): string {
+
const days = [
+
"Sunday",
+
"Monday",
+
"Tuesday",
+
"Wednesday",
+
"Thursday",
+
"Friday",
+
"Saturday",
+
];
+
return days[date.getDay()] || "Unknown";
+
}
+
+
/**
+
* Checks if a meeting time label matches a specific day
+
* Labels like "Monday Lecture", "Tuesday Lab", "Wed Discussion" should match
+
*/
+
export function meetingTimeLabelMatchesDay(
+
label: string,
+
dayName: string,
+
): boolean {
+
const lowerLabel = label.toLowerCase();
+
const lowerDay = dayName.toLowerCase();
+
+
// Check for full day name
+
if (lowerLabel.includes(lowerDay)) {
+
return true;
+
}
+
+
// Check for 3-letter abbreviations
+
const abbrev = dayName.slice(0, 3).toLowerCase();
+
if (lowerLabel.includes(abbrev)) {
+
return true;
+
}
+
+
return false;
+
}
+
+
/**
+
* Finds the best matching meeting time for a given date
+
* @param date Date from audio metadata
+
* @param meetingTimes Available meeting times for the class
+
* @returns Meeting time ID or null if no match
+
*/
+
export function findMatchingMeetingTime(
+
date: Date,
+
meetingTimes: Array<{ id: string; label: string }>,
+
): string | null {
+
const dayName = getDayName(date);
+
+
// Find meeting time that matches the day
+
const match = meetingTimes.find((mt) =>
+
meetingTimeLabelMatchesDay(mt.label, dayName),
+
);
+
+
if (match) {
+
console.log(
+
`[AudioMetadata] Matched ${dayName} to meeting time: ${match.label}`,
+
);
+
return match.id;
+
}
+
+
console.log(
+
`[AudioMetadata] No meeting time found matching ${dayName} in available options: ${meetingTimes.map((mt) => mt.label).join(", ")}`,
+
);
+
return null;
+
}
+34
src/lib/auth.test.ts
···
};
expect(typeof result.count).toBe("number");
});
···
};
expect(typeof result.count).toBe("number");
});
+
+
test("enforces maximum session limit per user", () => {
+
const userId = 999;
+
+
// Clean up any existing sessions for this user
+
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
+
+
// Create 11 sessions (limit is 10)
+
const sessionIds: string[] = [];
+
for (let i = 0; i < 11; i++) {
+
const sessionId = createSession(userId, `192.168.1.${i}`, `Agent ${i}`);
+
sessionIds.push(sessionId);
+
}
+
+
// Count total sessions for user
+
const sessionCount = db
+
.query<{ count: number }, [number]>(
+
"SELECT COUNT(*) as count FROM sessions WHERE user_id = ?",
+
)
+
.get(userId);
+
+
expect(sessionCount?.count).toBe(10);
+
+
// First session should be deleted (oldest)
+
const firstSession = getSession(sessionIds[0]);
+
expect(firstSession).toBeNull();
+
+
// Last session should exist (newest)
+
const lastSession = getSession(sessionIds[10]);
+
expect(lastSession).not.toBeNull();
+
+
// Cleanup
+
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
+
});
+22
src/lib/auth.ts
···
import db from "../db/schema";
const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
export type UserRole = "user" | "admin";
···
): string {
const sessionId = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
db.run(
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",
···
import db from "../db/schema";
const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
+
const MAX_SESSIONS_PER_USER = 10; // Maximum number of sessions per user
export type UserRole = "user" | "admin";
···
): string {
const sessionId = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION;
+
+
// Check current session count for user
+
const sessionCount = db
+
.query<{ count: number }, [number]>(
+
"SELECT COUNT(*) as count FROM sessions WHERE user_id = ?",
+
)
+
.get(userId);
+
+
// If at or over limit, delete oldest session(s)
+
if (sessionCount && sessionCount.count >= MAX_SESSIONS_PER_USER) {
+
const sessionsToDelete = sessionCount.count - MAX_SESSIONS_PER_USER + 1;
+
db.run(
+
`DELETE FROM sessions WHERE id IN (
+
SELECT id FROM sessions
+
WHERE user_id = ?
+
ORDER BY created_at ASC
+
LIMIT ?
+
)`,
+
[userId, sessionsToDelete],
+
);
+
}
db.run(
"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",
+97 -7
src/lib/classes.ts
···
semester: string;
year: number;
archived: boolean;
created_at: number;
}
···
export interface ClassMember {
class_id: string;
user_id: number;
enrolled_at: number;
}
···
semester: string;
year: number;
meeting_times?: string[];
}): Class {
const id = nanoid();
const now = Math.floor(Date.now() / 1000);
···
if (data.meeting_times && data.meeting_times.length > 0) {
for (const label of data.meeting_times) {
createMeetingTime(id, label);
}
}
···
/**
* Enroll a user in a class
*/
-
export function enrollUserInClass(userId: number, classId: string): void {
const now = Math.floor(Date.now() / 1000);
db.run(
-
"INSERT OR IGNORE INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
-
[classId, userId, now],
);
}
···
id: string;
user_id: number;
meeting_time_id: string | null;
filename: string;
original_filename: string;
status: string;
···
},
[string]
>(
-
`SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at
FROM transcriptions
WHERE class_id = ?
-
ORDER BY created_at DESC`,
)
.all(classId);
}
···
export function joinClass(
classId: string,
userId: number,
): { success: boolean; error?: string } {
// Find class by ID
const cls = db
···
return { success: false, error: "Already enrolled in this class" };
}
// Enroll user
db.query(
-
"INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
-
).run(cls.id, userId, Math.floor(Date.now() / 1000));
return { success: true };
}
/**
···
semester: string;
year: number;
archived: boolean;
+
section_number?: string | null;
+
created_at: number;
+
}
+
+
export interface ClassSection {
+
id: string;
+
class_id: string;
+
section_number: string;
created_at: number;
}
···
export interface ClassMember {
class_id: string;
user_id: number;
+
section_id: string | null;
enrolled_at: number;
}
···
semester: string;
year: number;
meeting_times?: string[];
+
sections?: string[];
}): Class {
const id = nanoid();
const now = Math.floor(Date.now() / 1000);
···
if (data.meeting_times && data.meeting_times.length > 0) {
for (const label of data.meeting_times) {
createMeetingTime(id, label);
+
}
+
}
+
+
// Create sections if provided
+
if (data.sections && data.sections.length > 0) {
+
for (const sectionNumber of data.sections) {
+
createClassSection(id, sectionNumber);
}
}
···
/**
* Enroll a user in a class
*/
+
export function enrollUserInClass(
+
userId: number,
+
classId: string,
+
sectionId?: string | null,
+
): void {
const now = Math.floor(Date.now() / 1000);
db.run(
+
"INSERT OR IGNORE INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)",
+
[classId, userId, sectionId ?? null, now],
);
}
···
id: string;
user_id: number;
meeting_time_id: string | null;
+
section_id: string | null;
filename: string;
original_filename: string;
status: string;
···
},
[string]
>(
+
`SELECT id, user_id, meeting_time_id, section_id, filename, original_filename, status, progress, error_message, created_at, updated_at
FROM transcriptions
WHERE class_id = ?
+
ORDER BY recording_date DESC, created_at DESC`,
)
.all(classId);
}
···
export function joinClass(
classId: string,
userId: number,
+
sectionId?: string | null,
): { success: boolean; error?: string } {
// Find class by ID
const cls = db
···
return { success: false, error: "Already enrolled in this class" };
}
+
// Check if class has sections and require one to be selected
+
const sections = getClassSections(classId);
+
if (sections.length > 0 && !sectionId) {
+
return { success: false, error: "Please select a section" };
+
}
+
+
// If section provided, validate it exists and belongs to this class
+
if (sectionId) {
+
const section = sections.find((s) => s.id === sectionId);
+
if (!section) {
+
return { success: false, error: "Invalid section selected" };
+
}
+
}
+
// Enroll user
db.query(
+
"INSERT INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)",
+
).run(cls.id, userId, sectionId ?? null, Math.floor(Date.now() / 1000));
return { success: true };
+
}
+
+
/**
+
* Create a section for a class
+
*/
+
export function createClassSection(
+
classId: string,
+
sectionNumber: string,
+
): ClassSection {
+
const id = nanoid();
+
const now = Math.floor(Date.now() / 1000);
+
+
db.run(
+
"INSERT INTO class_sections (id, class_id, section_number, created_at) VALUES (?, ?, ?, ?)",
+
[id, classId, sectionNumber, now],
+
);
+
+
return {
+
id,
+
class_id: classId,
+
section_number: sectionNumber,
+
created_at: now,
+
};
+
}
+
+
/**
+
* Get all sections for a class
+
*/
+
export function getClassSections(classId: string): ClassSection[] {
+
return db
+
.query<ClassSection, [string]>(
+
"SELECT * FROM class_sections WHERE class_id = ? ORDER BY section_number ASC",
+
)
+
.all(classId);
+
}
+
+
/**
+
* Delete a class section
+
*/
+
export function deleteClassSection(sectionId: string): void {
+
db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]);
+
}
+
+
/**
+
* Get user's enrolled section for a class
+
*/
+
export function getUserSection(userId: number, classId: string): string | null {
+
const result = db
+
.query<{ section_id: string | null }, [string, number]>(
+
"SELECT section_id FROM class_members WHERE class_id = ? AND user_id = ?",
+
)
+
.get(classId, userId);
+
return result?.section_id ?? null;
}
/**
+3 -1
src/lib/email.ts
···
export async function sendEmail(options: SendEmailOptions): Promise<void> {
// Skip sending emails in test mode
if (process.env.NODE_ENV === "test" || process.env.SKIP_EMAILS === "true") {
-
console.log(`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`);
return;
}
···
export async function sendEmail(options: SendEmailOptions): Promise<void> {
// Skip sending emails in test mode
if (process.env.NODE_ENV === "test" || process.env.SKIP_EMAILS === "true") {
+
console.log(
+
`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`,
+
);
return;
}
+227
src/lib/voting.ts
···
···
+
import { nanoid } from "nanoid";
+
import db from "../db/schema";
+
+
/**
+
* Vote for a recording
+
* Returns true if vote was recorded, false if already voted
+
*/
+
export function voteForRecording(
+
transcriptionId: string,
+
userId: number,
+
): boolean {
+
try {
+
const voteId = nanoid();
+
db.run(
+
"INSERT INTO recording_votes (id, transcription_id, user_id) VALUES (?, ?, ?)",
+
[voteId, transcriptionId, userId],
+
);
+
+
// Increment vote count on transcription
+
db.run(
+
"UPDATE transcriptions SET vote_count = vote_count + 1 WHERE id = ?",
+
[transcriptionId],
+
);
+
+
return true;
+
} catch (error) {
+
// Unique constraint violation means user already voted
+
if (
+
error instanceof Error &&
+
error.message.includes("UNIQUE constraint failed")
+
) {
+
return false;
+
}
+
throw error;
+
}
+
}
+
+
/**
+
* Remove vote for a recording
+
*/
+
export function removeVote(transcriptionId: string, userId: number): boolean {
+
const result = db.run(
+
"DELETE FROM recording_votes WHERE transcription_id = ? AND user_id = ?",
+
[transcriptionId, userId],
+
);
+
+
if (result.changes > 0) {
+
// Decrement vote count on transcription
+
db.run(
+
"UPDATE transcriptions SET vote_count = vote_count - 1 WHERE id = ?",
+
[transcriptionId],
+
);
+
return true;
+
}
+
+
return false;
+
}
+
+
/**
+
* Get user's vote for a specific class meeting time
+
*/
+
export function getUserVoteForMeeting(
+
userId: number,
+
classId: string,
+
meetingTimeId: string,
+
): string | null {
+
const result = db
+
.query<
+
{ transcription_id: string },
+
[number, string, string]
+
>(
+
`SELECT rv.transcription_id
+
FROM recording_votes rv
+
JOIN transcriptions t ON rv.transcription_id = t.id
+
WHERE rv.user_id = ?
+
AND t.class_id = ?
+
AND t.meeting_time_id = ?
+
AND t.status = 'pending'`,
+
)
+
.get(userId, classId, meetingTimeId);
+
+
return result?.transcription_id || null;
+
}
+
+
/**
+
* Get all pending recordings for a class meeting time (filtered by section)
+
*/
+
export function getPendingRecordings(
+
classId: string,
+
meetingTimeId: string,
+
sectionId?: string | null,
+
) {
+
// Build query based on whether section filtering is needed
+
let query = `SELECT id, user_id, filename, original_filename, vote_count, created_at, section_id
+
FROM transcriptions
+
WHERE class_id = ?
+
AND meeting_time_id = ?
+
AND status = 'pending'`;
+
+
const params: (string | null)[] = [classId, meetingTimeId];
+
+
// Filter by section if provided (for voting - section-specific)
+
if (sectionId !== undefined) {
+
query += " AND (section_id = ? OR section_id IS NULL)";
+
params.push(sectionId);
+
}
+
+
query += " ORDER BY vote_count DESC, created_at ASC";
+
+
return db
+
.query<
+
{
+
id: string;
+
user_id: number;
+
filename: string;
+
original_filename: string;
+
vote_count: number;
+
created_at: number;
+
section_id: string | null;
+
},
+
(string | null)[]
+
>(query)
+
.all(...params);
+
}
+
+
/**
+
* Get total enrolled users count for a class
+
*/
+
export function getEnrolledUserCount(classId: string): number {
+
const result = db
+
.query<{ count: number }, [string]>(
+
"SELECT COUNT(*) as count FROM class_members WHERE class_id = ?",
+
)
+
.get(classId);
+
+
return result?.count || 0;
+
}
+
+
/**
+
* Check if recording should be auto-submitted
+
* Returns winning recording ID if ready, null otherwise
+
*/
+
export function checkAutoSubmit(
+
classId: string,
+
meetingTimeId: string,
+
sectionId?: string | null,
+
): string | null {
+
const recordings = getPendingRecordings(classId, meetingTimeId, sectionId);
+
+
if (recordings.length === 0) {
+
return null;
+
}
+
+
const totalUsers = getEnrolledUserCount(classId);
+
const now = Date.now() / 1000; // Current time in seconds
+
+
// Get the recording with most votes
+
const topRecording = recordings[0];
+
if (!topRecording) return null;
+
+
const uploadedAt = topRecording.created_at;
+
const timeSinceUpload = now - uploadedAt;
+
+
// Auto-submit if:
+
// 1. 30 minutes have passed since first upload, OR
+
// 2. 40% of enrolled users have voted for the top recording
+
const thirtyMinutes = 30 * 60; // 30 minutes in seconds
+
const voteThreshold = Math.ceil(totalUsers * 0.4);
+
+
if (timeSinceUpload >= thirtyMinutes) {
+
console.log(
+
`[Voting] Auto-submitting ${topRecording.id} - 30 minutes elapsed`,
+
);
+
return topRecording.id;
+
}
+
+
if (topRecording.vote_count >= voteThreshold) {
+
console.log(
+
`[Voting] Auto-submitting ${topRecording.id} - reached ${topRecording.vote_count}/${voteThreshold} votes (40% threshold)`,
+
);
+
return topRecording.id;
+
}
+
+
return null;
+
}
+
+
/**
+
* Mark a recording as auto-submitted and start transcription
+
*/
+
export function markAsAutoSubmitted(transcriptionId: string): void {
+
db.run(
+
"UPDATE transcriptions SET auto_submitted = 1 WHERE id = ?",
+
[transcriptionId],
+
);
+
}
+
+
/**
+
* Delete a pending recording (only allowed by uploader or admin)
+
*/
+
export function deletePendingRecording(
+
transcriptionId: string,
+
userId: number,
+
isAdmin: boolean,
+
): boolean {
+
// Check ownership if not admin
+
if (!isAdmin) {
+
const recording = db
+
.query<{ user_id: number; status: string }, [string]>(
+
"SELECT user_id, status FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptionId);
+
+
if (!recording || recording.user_id !== userId) {
+
return false;
+
}
+
+
// Only allow deleting pending recordings
+
if (recording.status !== "pending") {
+
return false;
+
}
+
}
+
+
// Delete the recording (cascades to votes)
+
db.run("DELETE FROM transcriptions WHERE id = ?", [transcriptionId]);
+
+
return true;
+
}
+3 -3
src/lib/vtt-cleaner.test.ts
···
test("cleanVTT preserves empty VTT", async () => {
const emptyVTT = "WEBVTT\n\n";
-
// Save and remove API key to avoid burning tokens
const originalKey = process.env.LLM_API_KEY;
delete process.env.LLM_API_KEY;
-
const result = await cleanVTT("test-empty", emptyVTT);
expect(result).toBe(emptyVTT);
-
// Restore original key
if (originalKey) {
process.env.LLM_API_KEY = originalKey;
···
test("cleanVTT preserves empty VTT", async () => {
const emptyVTT = "WEBVTT\n\n";
+
// Save and remove API key to avoid burning tokens
const originalKey = process.env.LLM_API_KEY;
delete process.env.LLM_API_KEY;
+
const result = await cleanVTT("test-empty", emptyVTT);
expect(result).toBe(emptyVTT);
+
// Restore original key
if (originalKey) {
process.env.LLM_API_KEY = originalKey;
-1
src/pages/admin.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Admin - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
-1
src/pages/checkout.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Success! - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Success! - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
-1
src/pages/class.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Class - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Class - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
-1
src/pages/classes.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Classes - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Classes - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
-1
src/pages/index.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
-1
src/pages/reset-password.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Reset Password - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
-1
src/pages/settings.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Settings - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
-1
src/pages/transcribe.html
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
<title>Transcribe - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
···
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Transcribe - Thistle</title>
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">