🪻 distributed transcription service thistle.dunkirk.sh

Compare changes

Choose any two refs to compare.

+10
LICENSE.md
···
+
# The O'Saasy License
+
+
Copyright © `2025` `Kieran Klukas`
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
+
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2 -1
README.md
···
```
Or manually:
+
```bash
cd whisper-server
pip install -r requirements.txt
···
</p>
<p align="center">
-
<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
+
<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=O'Saasy&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
</p>
+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() 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" : ""}">
+
<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 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 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;
+
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) {
+
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 }),
+
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 {
+
} catch (error) {
+
console.error("Failed to join class:", error);
this.error = "Failed to join class. Please try again.";
-
} finally {
this.isJoining = false;
+
this.requestUpdate();
}
}
···
<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-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();
-
this.handleJoin(cls.id);
+
console.log('Join button clicked for class:', cls.id, 'sections:', cls.sections);
+
this.handleJoin(cls.id, cls.sections);
}}
>
${this.isJoining ? "Joining..." : "Join"}
···
: ""
}
</div>
-
</button>
+
</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";
+
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() {
-
if (!this.searchQuery) return this.transcriptions;
+
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),
+
);
+
}
-
const query = this.searchQuery.toLowerCase();
-
return this.transcriptions.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}</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() {
+
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;
}
+
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 handleFileSelect(e: Event) {
+
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 handleMeetingTimeChange(e: Event) {
-
const select = e.target as HTMLSelectElement;
-
this.selectedMeetingTimeId = select.value || null;
-
}
+
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();
-
private handleClose() {
-
if (this.uploading) return;
-
this.open = false;
-
this.selectedFile = null;
-
this.selectedMeetingTimeId = null;
-
this.error = null;
-
this.dispatchEvent(new CustomEvent("close"));
-
}
+
// 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";
+
}
+
});
-
private async handleUpload() {
-
if (!this.selectedFile) {
-
this.error = "Please select a file to upload";
-
return;
-
}
+
// Handle errors
+
xhr.addEventListener("error", () => {
+
this.uploading = false;
+
this.error = "Upload failed. Please try again.";
+
});
-
if (!this.selectedMeetingTimeId) {
-
this.error = "Please select a meeting time";
-
return;
+
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.";
}
+
}
-
this.uploading = true;
-
this.error = null;
+
private async detectMeetingTime() {
+
if (!this.classId) return;
+
+
this.detectingMeetingTime = true;
try {
const formData = new FormData();
-
formData.append("audio", this.selectedFile);
formData.append("class_id", this.classId);
-
formData.append("meeting_time_id", this.selectedMeetingTimeId);
+
+
// 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;
+
}
-
const response = await fetch("/api/transcriptions", {
+
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();
-
throw new Error(data.error || "Upload failed");
+
this.error = data.error || "Failed to update meeting time";
+
this.submitting = false;
+
return;
}
-
// Success - close modal and notify parent
+
// Success - close modal and refresh
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;
+
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>
-
<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>
+
${
+
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}>
+
<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}>
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>
+
${
+
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";
+
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);
+
`,
},
];
+213 -667
src/index.test.ts
···
expect,
test,
} from "bun:test";
-
import { hashPasswordClient } from "./lib/client-auth";
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) {
···
}
console.log("✓ Test server stopped and test database cleaned up");
+
});
+
+
// 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 password_reset_tokens");
+
db.run("DELETE FROM email_verification_tokens");
+
db.run("DELETE FROM passkeys");
+
db.run("DELETE FROM sessions");
+
db.run("DELETE FROM subscriptions");
+
db.run("DELETE FROM transcriptions");
+
db.run("DELETE FROM class_members");
+
db.run("DELETE FROM meeting_times");
+
db.run("DELETE FROM classes");
+
db.run("DELETE FROM class_waitlist");
+
db.run("DELETE FROM users WHERE id != 0"); // Keep ghost user
+
+
db.close();
});
// Test user credentials
···
});
}
+
// 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
+
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email: user.email,
+
password: hashedPassword,
+
name: user.name || "Test User",
+
}),
+
});
+
+
if (registerResponse.status !== 201) {
+
const error = await registerResponse.json();
+
throw new Error(`Registration failed: ${JSON.stringify(error)}`);
+
}
+
+
const registerData = await registerResponse.json();
+
const userId = registerData.user.id;
+
+
// Mark email as verified directly in the database (test mode)
+
const db = require("bun:sqlite").Database.open(TEST_DB_PATH);
+
db.run("UPDATE users SET email_verified = 1 WHERE id = ?", [userId]);
+
db.close();
+
+
// Now login to get a session
+
const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email: user.email,
+
password: hashedPassword,
+
}),
+
});
+
+
if (loginResponse.status !== 200) {
+
const error = await loginResponse.json();
+
throw new Error(`Login failed: ${JSON.stringify(error)}`);
+
}
+
+
return extractSessionCookie(loginResponse);
+
}
+
+
// 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();
+
}
+
// All tests run against a fresh database, no cleanup needed
describe("API Endpoints - Authentication", () => {
···
}),
});
-
if (response.status !== 200) {
+
if (response.status !== 201) {
const error = await response.json();
console.error("Registration failed:", response.status, error);
}
-
expect(response.status).toBe(200);
-
-
// Extract session before consuming response body
-
const sessionCookie = extractSessionCookie(response);
-
+
expect(response.status).toBe(201);
+
const data = await response.json();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(TEST_USER.email);
-
expect(sessionCookie).toBeTruthy();
+
expect(data.email_verification_required).toBe(true);
});
test("should reject registration with missing email", async () => {
···
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(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
-
// First registration
-
await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
name: TEST_USER.name,
-
}),
-
});
-
-
// Duplicate registration
+
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: hashedPassword,
-
name: TEST_USER.name,
+
password: "short",
}),
});
expect(response.status).toBe(400);
const data = await response.json();
-
expect(data.error).toBe("Email already registered");
-
});
-
-
test("should enforce rate limiting on registration", async () => {
-
const hashedPassword = await clientHashPassword(
-
"test@example.com",
-
"password",
-
);
-
-
// Make registration attempts until rate limit is hit (limit is 5 per hour)
-
let rateLimitHit = false;
-
for (let i = 0; i < 10; i++) {
-
const response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: `test${i}@example.com`,
-
password: hashedPassword,
-
}),
-
});
-
-
if (response.status === 429) {
-
rateLimitHit = true;
-
break;
-
}
-
}
-
-
// Verify that rate limiting was triggered
-
expect(rateLimitHit).toBe(true);
+
expect(data.error).toBe("Invalid password format");
});
-
});
-
describe("POST /api/auth/login", () => {
-
test("should login successfully with valid credentials", async () => {
-
// Register user first
+
test("should reject duplicate email registration", async () => {
const hashedPassword = await clientHashPassword(
TEST_USER.email,
TEST_USER.password,
);
+
+
// First registration
await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
···
}),
});
-
// Login
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
+
// Duplicate registration
+
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: TEST_USER.email,
password: hashedPassword,
+
name: TEST_USER.name,
}),
});
-
expect(response.status).toBe(200);
+
expect(response.status).toBe(409);
const data = await response.json();
-
expect(data.user).toBeDefined();
-
expect(data.user.email).toBe(TEST_USER.email);
-
expect(extractSessionCookie(response)).toBeTruthy();
+
expect(data.error).toBe("Email already registered");
});
-
test("should reject login with invalid credentials", async () => {
-
// Register user first
+
test("should enforce rate limiting on registration", async () => {
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
+
"ratelimit@example.com",
+
"password",
);
+
+
// First registration succeeds
await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
-
email: TEST_USER.email,
+
email: "ratelimit@example.com",
password: hashedPassword,
}),
});
-
// Login with wrong password
-
const wrongPassword = await clientHashPassword(
-
TEST_USER.email,
-
"WrongPassword123!",
-
);
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: wrongPassword,
-
}),
-
});
-
-
expect(response.status).toBe(401);
-
const data = await response.json();
-
expect(data.error).toBe("Invalid email or password");
-
});
-
-
test("should reject login with missing fields", async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
}),
-
});
-
-
expect(response.status).toBe(400);
-
const data = await response.json();
-
expect(data.error).toBe("Email and password required");
-
});
-
-
test("should enforce rate limiting on login attempts", async () => {
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
-
// Make 11 login attempts (limit is 10 per 15 minutes per IP)
+
// Try to register same email 10 more times (will fail with 400 but count toward rate limit)
+
// Rate limit is 5 per 30 min from same IP
let rateLimitHit = false;
-
for (let i = 0; i < 11; i++) {
-
const response = await fetch(`${BASE_URL}/api/auth/login`, {
+
for (let i = 0; i < 10; i++) {
+
const response = await fetch(`${BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
-
email: TEST_USER.email,
+
email: "ratelimit@example.com",
password: hashedPassword,
}),
});
···
});
});
-
describe("POST /api/auth/logout", () => {
-
test("should logout successfully", async () => {
+
describe("POST /api/auth/login", () => {
+
test("should login successfully with valid credentials", async () => {
// Register and login
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const loginResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(loginResponse);
-
-
// Logout
-
const response = await authRequest(
-
`${BASE_URL}/api/auth/logout`,
-
sessionCookie,
-
{
-
method: "POST",
-
},
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
-
-
// Verify cookie is cleared
-
const setCookie = response.headers.get("set-cookie");
-
expect(setCookie).toContain("Max-Age=0");
-
});
-
-
test("should logout even without valid session", async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/logout`, {
-
method: "POST",
-
});
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
-
});
-
});
-
-
describe("GET /api/auth/me", () => {
-
test(
-
"should return current user info when authenticated",
-
async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
name: TEST_USER.name,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
-
-
// Get current user
-
const response = await authRequest(
-
`${BASE_URL}/api/auth/me`,
-
sessionCookie,
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.email).toBe(TEST_USER.email);
-
expect(data.name).toBe(TEST_USER.name);
-
expect(data.role).toBeDefined();
-
},
-
);
-
-
test("should return 401 when not authenticated", async () => {
-
const response = await fetch(`${BASE_URL}/api/auth/me`);
-
-
expect(response.status).toBe(401);
-
const data = await response.json();
-
expect(data.error).toBe("Not authenticated");
-
});
-
-
test("should return 401 with invalid session", async () => {
-
const response = await authRequest(
-
`${BASE_URL}/api/auth/me`,
-
"invalid-session",
-
);
-
-
expect(response.status).toBe(401);
-
const data = await response.json();
-
expect(data.error).toBe("Invalid session");
-
});
-
});
-
});
-
-
describe("API Endpoints - Session Management", () => {
-
describe("GET /api/sessions", () => {
-
test("should return user sessions", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
-
-
// Get sessions
-
const response = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
sessionCookie,
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.sessions).toBeDefined();
-
expect(data.sessions.length).toBeGreaterThan(0);
-
expect(data.sessions[0]).toHaveProperty("id");
-
expect(data.sessions[0]).toHaveProperty("ip_address");
-
expect(data.sessions[0]).toHaveProperty("user_agent");
-
});
-
-
test("should require authentication", async () => {
-
const response = await fetch(`${BASE_URL}/api/sessions`);
-
-
expect(response.status).toBe(401);
-
});
-
});
-
-
describe("DELETE /api/sessions", () => {
-
test("should delete specific session", async () => {
-
// Register user and create multiple sessions
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const session1Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const session1Cookie = extractSessionCookie(session1Response);
-
-
const session2Response = await fetch(`${BASE_URL}/api/auth/login`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const session2Cookie = extractSessionCookie(session2Response);
-
-
// Get sessions list
-
const sessionsResponse = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
session1Cookie,
-
);
-
const sessionsData = await sessionsResponse.json();
-
const targetSessionId = sessionsData.sessions.find(
-
(s: { id: string }) => s.id === session2Cookie,
-
)?.id;
-
-
// Delete session 2
-
const response = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
session1Cookie,
-
{
-
method: "DELETE",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ sessionId: targetSessionId }),
-
},
-
);
-
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
-
-
// Verify session 2 is deleted
-
const verifyResponse = await authRequest(
-
`${BASE_URL}/api/auth/me`,
-
session2Cookie,
-
);
-
expect(verifyResponse.status).toBe(401);
-
});
-
-
test("should not delete another user's session", async () => {
-
// Register two users
-
const hashedPassword1 = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const user1Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword1,
-
}),
-
});
-
const user1Cookie = extractSessionCookie(user1Response);
-
-
const hashedPassword2 = await clientHashPassword(
-
TEST_USER_2.email,
-
TEST_USER_2.password,
-
);
-
const user2Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER_2.email,
-
password: hashedPassword2,
-
}),
-
});
-
const user2Cookie = extractSessionCookie(user2Response);
-
-
// Try to delete user2's session using user1's credentials
-
const response = await authRequest(
-
`${BASE_URL}/api/sessions`,
-
user1Cookie,
-
{
-
method: "DELETE",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ sessionId: user2Cookie }),
-
},
-
);
-
-
expect(response.status).toBe(404);
-
});
-
-
test("should not delete current session", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Try to delete own current session
const response = await authRequest(
···
describe("API Endpoints - User Management", () => {
describe("DELETE /api/user", () => {
test("should delete user account", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Delete account
const response = await authRequest(
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
+
expect(response.status).toBe(204);
// Verify user is deleted
const verifyResponse = await authRequest(
···
describe("PUT /api/user/email", () => {
test("should update user email", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
-
// Update email
+
// Update email - this creates a token but doesn't change email yet
const newEmail = "newemail@example.com";
const response = await authRequest(
`${BASE_URL}/api/user/email`,
···
const data = await response.json();
expect(data.success).toBe(true);
+
// 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
const meResponse = await authRequest(
`${BASE_URL}/api/auth/me`,
···
test("should reject duplicate email", async () => {
// Register two users
-
const hashedPassword1 = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword1,
-
}),
-
});
-
-
const hashedPassword2 = await clientHashPassword(
-
TEST_USER_2.email,
-
TEST_USER_2.password,
-
);
-
const user2Response = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER_2.email,
-
password: hashedPassword2,
-
}),
-
});
-
const user2Cookie = extractSessionCookie(user2Response);
+
await registerAndLogin(TEST_USER);
+
const user2Cookie = await registerAndLogin(TEST_USER_2);
// Try to update user2's email to user1's email
const response = await authRequest(
···
},
);
-
expect(response.status).toBe(400);
+
expect(response.status).toBe(409);
const data = await response.json();
expect(data.error).toBe("Email already in use");
});
···
describe("PUT /api/user/password", () => {
test("should update user password", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Update password
const newPassword = await clientHashPassword(
···
});
test("should reject invalid password format", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Try to update with invalid format
const response = await authRequest(
···
describe("PUT /api/user/name", () => {
test("should update user name", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
name: TEST_USER.name,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Update name
const newName = "Updated Name";
···
);
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
// Verify name updated
const meResponse = await authRequest(
···
});
test("should reject missing name", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
const response = await authRequest(
`${BASE_URL}/api/user/name`,
···
describe("PUT /api/user/avatar", () => {
test("should update user avatar", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
// Update avatar
const newAvatar = "👨‍💻";
···
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`);
+
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");
-
},
-
);
+
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");
+
});
});
});
describe("API Endpoints - Transcriptions", () => {
describe("GET /api/transcriptions", () => {
test("should return user transcriptions", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Get transcriptions
const response = await authRequest(
···
describe("POST /api/transcriptions", () => {
test("should upload audio file and start transcription", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Create a test audio file
const audioBlob = new Blob(["fake audio data"], { type: "audio/mp3" });
···
},
);
-
expect(response.status).toBe(200);
+
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 user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Try to upload non-audio file
const textBlob = new Blob(["text file"], { type: "text/plain" });
···
});
test("should reject files exceeding size limit", async () => {
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
const sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
const sessionCookie = await registerAndLogin(TEST_USER);
+
+
// Add subscription
+
addSubscription(TEST_USER.email);
// Create a file larger than 100MB (the actual limit)
const largeBlob = new Blob([new ArrayBuffer(101 * 1024 * 1024)], {
···
let userId: number;
beforeEach(async () => {
-
// Create admin user
-
const adminHash = await clientHashPassword(
-
TEST_ADMIN.email,
-
TEST_ADMIN.password,
-
);
-
const adminResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_ADMIN.email,
-
password: adminHash,
-
name: TEST_ADMIN.name,
-
}),
-
});
-
adminCookie = extractSessionCookie(adminResponse);
+
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 = ?", [
TEST_ADMIN.email,
]);
// Create regular user
-
const userHash = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const userResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: userHash,
-
name: TEST_USER.name,
-
}),
-
});
-
userCookie = extractSessionCookie(userResponse);
+
userCookie = await registerAndLogin(TEST_USER);
// Get user ID
const userIdResult = db
.query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
.get(TEST_USER.email);
userId = userIdResult?.id;
+
+
db.close();
});
describe("GET /api/admin/users", () => {
···
},
);
-
expect(response.status).toBe(200);
-
const data = await response.json();
-
expect(data.success).toBe(true);
+
expect(response.status).toBe(204);
// 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);
+
expect(response.status).toBe(409);
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);
+
expect(response.status).toBe(204);
// Verify sessions are deleted
const verifyResponse = await authRequest(
···
let sessionCookie: string;
beforeEach(async () => {
-
-
// Register user
-
const hashedPassword = await clientHashPassword(
-
TEST_USER.email,
-
TEST_USER.password,
-
);
-
const registerResponse = await fetch(`${BASE_URL}/api/auth/register`, {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
email: TEST_USER.email,
-
password: hashedPassword,
-
}),
-
});
-
sessionCookie = extractSessionCookie(registerResponse);
+
// Register and login
+
sessionCookie = await registerAndLogin(TEST_USER);
});
describe("GET /api/passkeys", () => {
···
});
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",
-
},
-
);
+
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");
-
},
-
);
+
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(
+814 -374
src/index.ts
···
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") {
+
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),
+
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 },
+
{ 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: 400 },
+
{ status: 409 },
);
}
console.error("[Auth] Registration error:", err);
···
},
"/api/auth/me": {
GET: (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
// Check subscription status
-
const subscription = db
-
.query<{ status: string }, [number]>(
-
"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
-
)
-
.get(user.id);
+
// Check subscription status
+
const subscription = db
+
.query<{ status: string }, [number]>(
+
"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
+
)
+
.get(user.id);
-
// Get notification preferences
-
const prefs = db
-
.query<{ email_notifications_enabled: number }, [number]>(
-
"SELECT email_notifications_enabled FROM users WHERE id = ?",
-
)
-
.get(user.id);
+
// Get notification preferences
+
const prefs = db
+
.query<{ email_notifications_enabled: number }, [number]>(
+
"SELECT email_notifications_enabled FROM users WHERE id = ?",
+
)
+
.get(user.id);
-
return Response.json({
-
email: user.email,
-
name: user.name,
-
avatar: user.avatar,
-
created_at: user.created_at,
-
role: user.role,
-
has_subscription: !!subscription,
-
email_verified: isEmailVerified(user.id),
-
email_notifications_enabled: prefs?.email_notifications_enabled === 1,
-
});
+
return Response.json({
+
email: user.email,
+
name: user.name,
+
avatar: user.avatar,
+
created_at: user.created_at,
+
role: user.role,
+
has_subscription: !!subscription,
+
email_verified: isEmailVerified(user.id),
+
email_notifications_enabled:
+
prefs?.email_notifications_enabled === 1,
+
});
+
} catch (err) {
+
return handleError(err);
+
}
},
},
"/api/passkeys/register/options": {
···
updatePasskeyName(passkeyId, user.id, name);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (err) {
return handleError(err);
···
const passkeyId = req.params.id;
deletePasskey(passkeyId, user.id);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (err) {
return handleError(err);
···
},
"/api/sessions": {
GET: (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
+
try {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json(
+
{ error: "Not authenticated" },
+
{ status: 401 },
+
);
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
const sessions = getUserSessionsForUser(user.id);
+
return Response.json({
+
sessions: sessions.map((s) => ({
+
id: s.id,
+
ip_address: s.ip_address,
+
user_agent: s.user_agent,
+
created_at: s.created_at,
+
expires_at: s.expires_at,
+
is_current: s.id === sessionId,
+
})),
+
});
+
} catch (err) {
+
return handleError(err);
-
const sessions = getUserSessionsForUser(user.id);
-
return Response.json({
-
sessions: sessions.map((s) => ({
-
id: s.id,
-
ip_address: s.ip_address,
-
user_agent: s.user_agent,
-
created_at: s.created_at,
-
expires_at: s.expires_at,
-
is_current: s.id === sessionId,
-
})),
-
});
},
DELETE: async (req) => {
-
const currentSessionId = getSessionFromRequest(req);
-
if (!currentSessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(currentSessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const currentSessionId = getSessionFromRequest(req);
+
if (!currentSessionId) {
+
return Response.json(
+
{ error: "Not authenticated" },
+
{ status: 401 },
+
);
+
}
+
const user = getUserBySession(currentSessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
-
const rateLimitError = enforceRateLimit(req, "delete-session", {
-
ip: { max: 20, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
const rateLimitError = enforceRateLimit(req, "delete-session", {
+
ip: { max: 20, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
const body = await req.json();
-
const targetSessionId = body.sessionId;
-
if (!targetSessionId) {
-
return Response.json(
-
{ error: "Session ID required" },
-
{ status: 400 },
-
);
-
}
-
// Prevent deleting current session
-
if (targetSessionId === currentSessionId) {
-
return Response.json(
-
{ error: "Cannot kill current session. Use logout instead." },
-
{ status: 400 },
-
);
-
}
-
// 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 });
+
const body = await req.json();
+
const targetSessionId = body.sessionId;
+
if (!targetSessionId) {
+
return Response.json(
+
{ error: "Session ID required" },
+
{ status: 400 },
+
);
+
}
+
// Prevent deleting current session
+
if (targetSessionId === currentSessionId) {
+
return Response.json(
+
{ error: "Cannot kill current session. Use logout instead." },
+
{ status: 400 },
+
);
+
}
+
// 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);
-
deleteSession(targetSessionId);
-
return Response.json({ success: true });
},
},
"/api/user": {
DELETE: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "delete-user", {
-
ip: { max: 3, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "delete-user", {
+
ip: { max: 3, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
await deleteUser(user.id);
-
return Response.json(
-
{ success: true },
-
{
+
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);
+
}
},
},
"/api/user/email": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "update-email", {
-
ip: { max: 5, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "update-email", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
const body = await req.json();
-
const { email } = body;
-
if (!email) {
-
return Response.json({ error: "Email required" }, { status: 400 });
-
}
+
const body = await req.json();
+
const { email } = body;
+
if (!email) {
+
return Response.json({ error: "Email required" }, { status: 400 });
+
}
-
// Check if email is already in use
-
const existingUser = getUserByEmail(email);
-
if (existingUser) {
-
return Response.json(
-
{ error: "Email already in use" },
-
{ status: 400 },
-
);
-
}
+
// Check if email is already in use
+
const existingUser = getUserByEmail(email);
+
if (existingUser) {
+
return Response.json(
+
{ error: "Email already in use" },
+
{ status: 409 },
+
);
+
}
-
try {
-
// Create email change token
-
const token = createEmailChangeToken(user.id, email);
+
try {
+
// Create email change token
+
const token = createEmailChangeToken(user.id, email);
-
// Send verification email to the CURRENT address
-
const origin = process.env.ORIGIN || "http://localhost:3000";
-
const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
+
// Send verification email to the CURRENT address
+
const origin = process.env.ORIGIN || "http://localhost:3000";
+
const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
-
await sendEmail({
-
to: user.email,
-
subject: "Verify your email change",
-
html: emailChangeTemplate({
-
name: user.name,
-
currentEmail: user.email,
-
newEmail: email,
-
verifyLink: verifyUrl,
-
}),
-
});
+
await sendEmail({
+
to: user.email,
+
subject: "Verify your email change",
+
html: emailChangeTemplate({
+
name: user.name,
+
currentEmail: user.email,
+
newEmail: email,
+
verifyLink: verifyUrl,
+
}),
+
});
-
return Response.json({
-
success: true,
-
message: `Verification email sent to ${user.email}`,
-
pendingEmail: email,
-
});
-
} catch (error) {
-
console.error(
-
"[Email] Failed to send email change verification:",
-
error,
-
);
-
return Response.json(
-
{ error: "Failed to send verification email" },
-
{ status: 500 },
-
);
+
return Response.json({
+
success: true,
+
message: `Verification email sent to ${user.email}`,
+
pendingEmail: email,
+
});
+
} catch (error) {
+
console.error(
+
"[Email] Failed to send email change verification:",
+
error,
+
);
+
return Response.json(
+
{ error: "Failed to send verification email" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
},
},
···
},
"/api/user/password": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
// Rate limiting
-
const rateLimitError = enforceRateLimit(req, "update-password", {
-
ip: { max: 5, windowSeconds: 60 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "update-password", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
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);
-
if (!passwordValidation.valid) {
-
return Response.json(
-
{ error: passwordValidation.error },
-
{ status: 400 },
-
);
-
}
-
try {
-
await updateUserPassword(user.id, password);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update password" },
-
{ status: 500 },
-
);
+
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);
+
if (!passwordValidation.valid) {
+
return Response.json(
+
{ error: passwordValidation.error },
+
{ status: 400 },
+
);
+
}
+
try {
+
await updateUserPassword(user.id, password);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update password" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
},
},
"/api/user/name": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
const rateLimitError = enforceRateLimit(req, "update-name", {
-
ip: { max: 10, windowSeconds: 5 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
const rateLimitError = enforceRateLimit(req, "update-name", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
const body = await req.json();
-
const { name } = body;
-
if (!name) {
-
return Response.json({ error: "Name required" }, { status: 400 });
-
}
-
try {
-
updateUserName(user.id, name);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update name" },
-
{ status: 500 },
-
);
+
const body = await req.json();
+
const { name } = body;
+
if (!name) {
+
return Response.json({ error: "Name required" }, { status: 400 });
+
}
+
try {
+
updateUserName(user.id, name);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update name" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
},
},
"/api/user/avatar": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
const rateLimitError = enforceRateLimit(req, "update-avatar", {
-
ip: { max: 10, windowSeconds: 5 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
const rateLimitError = enforceRateLimit(req, "update-avatar", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
const body = await req.json();
-
const { avatar } = body;
-
if (!avatar) {
-
return Response.json({ error: "Avatar required" }, { status: 400 });
-
}
-
try {
-
updateUserAvatar(user.id, avatar);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update avatar" },
-
{ status: 500 },
-
);
+
const body = await req.json();
+
const { avatar } = body;
+
if (!avatar) {
+
return Response.json({ error: "Avatar required" }, { status: 400 });
+
}
+
try {
+
updateUserAvatar(user.id, avatar);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update avatar" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
},
},
"/api/user/notifications": {
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
const rateLimitError = enforceRateLimit(req, "update-notifications", {
-
ip: { max: 10, windowSeconds: 5 * 60 },
-
});
-
if (rateLimitError) return rateLimitError;
+
const rateLimitError = enforceRateLimit(req, "update-notifications", {
+
ip: { max: 10, windowSeconds: 5 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
-
const body = await req.json();
-
const { email_notifications_enabled } = body;
-
if (typeof email_notifications_enabled !== "boolean") {
-
return Response.json(
-
{ error: "email_notifications_enabled must be a boolean" },
-
{ status: 400 },
-
);
-
}
-
try {
-
db.run(
-
"UPDATE users SET email_notifications_enabled = ? WHERE id = ?",
-
[email_notifications_enabled ? 1 : 0, user.id],
-
);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update notification settings" },
-
{ status: 500 },
-
);
+
const body = await req.json();
+
const { email_notifications_enabled } = body;
+
if (typeof email_notifications_enabled !== "boolean") {
+
return Response.json(
+
{ error: "email_notifications_enabled must be a boolean" },
+
{ status: 400 },
+
);
+
}
+
try {
+
db.run(
+
"UPDATE users SET email_notifications_enabled = ? WHERE id = ?",
+
[email_notifications_enabled ? 1 : 0, user.id],
+
);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update notification settings" },
+
{ status: 500 },
+
);
+
}
+
} catch (err) {
+
return handleError(err);
},
},
"/api/billing/checkout": {
POST: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
try {
const { polar } = await import("./lib/polar");
// Validated at startup
···
});
return Response.json({ url: checkout.url });
-
} catch (error) {
-
console.error("Failed to create checkout:", error);
-
return Response.json(
-
{ error: "Failed to create checkout session" },
-
{ status: 500 },
-
);
+
} catch (err) {
+
return handleError(err);
},
},
"/api/billing/subscription": {
GET: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
try {
// Get subscription from database
const subscription = db
.query<
···
return Response.json({ subscription });
-
} catch (error) {
-
console.error("Failed to fetch subscription:", error);
-
return Response.json(
-
{ error: "Failed to fetch subscription" },
-
{ status: 500 },
-
);
+
} catch (err) {
+
return handleError(err);
},
},
"/api/billing/portal": {
POST: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
+
try {
+
const user = requireAuth(req);
-
try {
const { polar } = await import("./lib/polar");
// Get subscription to find customer ID
···
});
return Response.json({ url: session.customerPortalUrl });
-
} catch (error) {
-
console.error("Failed to create portal session:", error);
-
return Response.json(
-
{ error: "Failed to create portal session" },
-
{ status: 500 },
-
);
+
} catch (err) {
+
return handleError(err);
},
},
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
+
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: "Transcription not found" },
-
{ status: 404 },
-
);
+
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: 400 },
+
{ status: 409 },
);
···
// Allow access if: owner, admin, or enrolled in the class
if (!isOwner && !isAdmin && !isClassMember) {
-
return Response.json(
-
{ error: "Transcription not found" },
-
{ status: 404 },
-
);
+
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 meetingTimeId = formData.get("meeting_time_id") as
+
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);
-
// Create database record
+
// 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, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
+
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
transcriptionId,
user.id,
classId,
-
meetingTimeId,
+
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",
-
});
+
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 Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
requireAdmin(req);
const transcriptionId = req.params.id;
deleteTranscription(transcriptionId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
await deleteUser(userId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
const { passkeyId } = req.params;
deletePasskey(passkeyId, userId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
if (existing) {
return Response.json(
{ error: "Email already in use" },
-
{ status: 400 },
+
{ status: 409 },
);
···
deleteAllUserSessions(userId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} 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
+
// Group by semester/year for all users
const grouped: Record<
string,
Array<{
···
semester,
year,
meeting_times,
+
sections: body.sections,
});
-
return Response.json(newClass);
+
return Response.json(newClass, { status: 201 });
} catch (error) {
return handleError(error);
···
.all(user.id)
.map((row) => row.class_id);
-
// Add is_enrolled flag to each class
+
// 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);
+
const result = joinClass(classId, user.id, sectionId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
meetingTimes || null,
);
-
return Response.json({ success: true, id });
+
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) {
···
requireAdmin(req);
const classId = req.params.id;
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
deleteClass(classId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
);
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
toggleClassArchive(classId, archived);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
return Response.json({ error: "Email required" }, { status: 400 });
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
const user = getUserByEmail(email);
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
enrollUserInClass(user.id, classId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 201 });
} catch (error) {
return handleError(error);
···
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
removeUserFromClass(userId, classId);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
return Response.json({ error: "Label required" }, { status: 400 });
+
// Verify class exists
+
const existingClass = getClassById(classId);
+
if (!existingClass) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
const meetingTime = createMeetingTime(classId, label);
-
return Response.json(meetingTime);
+
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);
···
return Response.json({ error: "Label required" }, { status: 400 });
+
// 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 });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
requireAdmin(req);
const meetingId = req.params.id;
+
// 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 });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
···
transcription.filename,
);
-
return Response.json({ success: true });
+
return new Response(null, { status: 204 });
} catch (error) {
return handleError(error);
},
},
},
-
development: {
-
hmr: true,
-
console: true,
+
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");
});
+
+
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
+
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 (?, ?, ?, ?, ?)",
+107 -7
src/lib/classes.ts
···
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): void {
+
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, enrolled_at) VALUES (?, ?, ?)",
-
[classId, userId, now],
+
"INSERT OR IGNORE INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)",
+
[classId, userId, sectionId ?? null, now],
);
}
···
}
/**
+
* Get a single meeting time by ID
+
*/
+
export function getMeetingById(meetingId: string): MeetingTime | null {
+
const result = db
+
.query<MeetingTime, [string]>("SELECT * FROM meeting_times WHERE id = ?")
+
.get(meetingId);
+
return result ?? null;
+
}
+
+
/**
* Update a meeting time label
*/
export function updateMeetingTime(meetingId: string, label: string): void {
···
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, filename, original_filename, status, progress, error_message, created_at, updated_at
+
`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 created_at DESC`,
+
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
···
if (existing) {
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, enrolled_at) VALUES (?, ?, ?)",
-
).run(cls.id, userId, Math.floor(Date.now() / 1000));
+
"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}`);
+
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;
-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">
-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">
-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">
-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">
-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">
-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">
-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">
-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">