🪻 distributed transcription service thistle.dunkirk.sh

feat: implement class system backend (database & API)

- Consolidate migrations into single schema with class system
- Add tables: classes, class_members, meeting_times
- Update transcriptions table with class_id, meeting_time_id, status
- Add class management lib with CRUD operations
- Add complete REST API for classes, enrollment, meetings
- Implement admin-driven transcription selection workflow
- Recordings default to 'pending' status (admin must select to transcribe)
- Add enrollment verification for class access
- Add archive support for classes
- Add getUserByEmail() to auth lib
- Include test suite and test script

💘 Generated with Crush

Co-Authored-By: Crush <crush@charm.land>

dunkirk.sh 21f12998 6bd6ac5d

verified
+3
bun.lock
···
"@simplewebauthn/server": "^13.2.2",
"eventsource-client": "^1.2.0",
"lit": "^3.3.1",
+
"nanoid": "^5.1.6",
"ua-parser-js": "^2.0.6",
},
"devDependencies": {
···
"lit-element": ["lit-element@4.2.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw=="],
"lit-html": ["lit-html@3.3.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA=="],
+
+
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
+264
docs/CLASS_SYSTEM_SPEC.md
···
+
# Class System Specification
+
+
## Overview
+
+
Restructure Thistle from individual transcript management to class-based transcript organization. Users will manage transcripts grouped by classes, with scheduled meeting times and selective transcription.
+
+
## User Flow
+
+
### 1. Classes Page (Home)
+
- Replaces the transcript page as the main view after signup
+
- Displays grid of class cards organized by semester/year
+
- Each section (semester/year combo) separated by horizontal rules
+
- Each card shows:
+
- Course code (e.g., "CS 101")
+
- Course name (e.g., "Introduction to Computer Science")
+
- Professor name
+
- Semester and year (e.g., "Fall 2024")
+
- Archive indicator (if archived)
+
- Final card in grid is "Register for Class" with centered plus icon
+
- Empty state: Only shows register button if user has no classes
+
+
### 2. Individual Class Page (`/classes/:id`)
+
- Lists all recordings and transcripts for the class
+
- Shows meeting schedule (flexible text, e.g., "Monday Lecture", "Wednesday Lab")
+
- Displays recordings with statuses:
+
- **Pending**: Uploaded but not selected for transcription
+
- **Selected**: Marked for transcription by admin
+
- **Transcribed**: Processing complete, ready to view
+
- **Failed**: Transcription failed
+
- Upload button to add new recordings
+
- Each recording tagged with meeting time
+
+
### 3. Recording Upload
+
- Any enrolled student can upload recordings
+
- Must select which meeting time the recording is for
+
- Recording enters "pending" state
+
- Does not auto-transcribe
+
+
### 4. Admin Workflow
+
- Admin views pending recordings
+
- Selects specific recording to transcribe for each meeting
+
- Only selected recordings get processed
+
- Can manage classes (create, archive, enrollments)
+
+
## Database Schema
+
+
### Classes Table
+
```sql
+
CREATE TABLE classes (
+
id TEXT PRIMARY KEY, -- stable random ID (nanoid or similar)
+
course_code TEXT NOT NULL, -- e.g., "CS 101"
+
name TEXT NOT NULL, -- e.g., "Introduction to Computer Science"
+
professor TEXT NOT NULL,
+
semester TEXT NOT NULL, -- e.g., "Fall", "Spring", "Summer"
+
year INTEGER NOT NULL, -- e.g., 2024
+
archived BOOLEAN DEFAULT FALSE,
+
created_at INTEGER NOT NULL
+
);
+
```
+
+
### Class Members Table
+
```sql
+
CREATE TABLE class_members (
+
class_id TEXT NOT NULL,
+
user_id TEXT NOT NULL,
+
enrolled_at INTEGER NOT NULL,
+
PRIMARY KEY (class_id, user_id),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
```
+
+
### Meeting Times Table
+
```sql
+
CREATE TABLE meeting_times (
+
id TEXT PRIMARY KEY,
+
class_id TEXT NOT NULL,
+
label TEXT NOT NULL, -- flexible text: "Monday Lecture", "Wednesday Lab", etc.
+
created_at INTEGER NOT NULL,
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
+
);
+
```
+
+
### Updated Transcripts Table
+
```sql
+
-- Add new columns to existing transcripts table:
+
ALTER TABLE transcripts ADD COLUMN class_id TEXT;
+
ALTER TABLE transcripts ADD COLUMN meeting_time_id TEXT;
+
ALTER TABLE transcripts ADD COLUMN status TEXT DEFAULT 'pending';
+
-- status: 'pending' | 'selected' | 'transcribed' | 'failed'
+
+
-- Add foreign keys:
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
+
FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL
+
```
+
+
**Note**: Add indexes for performance:
+
- `class_members(user_id)` - lookup user's classes
+
- `class_members(class_id)` - lookup class members
+
- `transcripts(class_id)` - lookup class transcripts
+
- `transcripts(status)` - filter by status
+
- `meeting_times(class_id)` - lookup class schedule
+
+
## Permissions
+
+
### Class Access
+
- Users can only view classes they're enrolled in
+
- Admins can view all classes
+
- Non-enrolled users get 403 when accessing `/classes/:id`
+
+
### Recording Permissions
+
- **Upload**: Any enrolled student can upload recordings
+
- **Delete**: Students can delete their own recordings
+
- **Select for transcription**: Admin only
+
- **View**: All enrolled students can view all transcripts in their classes
+
+
### Class Management
+
- **Create**: Admin only (via admin UI)
+
- **Archive**: Admin only (via admin UI)
+
- **Enroll students**: Admin only (via admin UI)
+
- **Remove students**: Admin only (via admin UI)
+
+
## Archive Behavior
+
+
When a class is archived:
+
- Students can still view the class and all transcripts
+
- No new recordings can be uploaded
+
- No recordings can be deleted
+
- No transcription selection allowed
+
- No enrollment changes
+
- Class appears with archive indicator in UI
+
- Organized with active classes by semester/year
+
+
## API Endpoints
+
+
### Classes
+
- `GET /api/classes` - List user's classes (grouped by semester/year)
+
- `GET /api/classes/:id` - Get class details (info, meeting times, transcripts)
+
- `POST /api/classes` (admin) - Create new class
+
- `PUT /api/classes/:id/archive` (admin) - Archive/unarchive class
+
- `DELETE /api/classes/:id` (admin) - Delete class
+
+
### Class Members
+
- `POST /api/classes/:id/members` (admin) - Enroll student(s)
+
- `DELETE /api/classes/:id/members/:userId` (admin) - Remove student
+
- `GET /api/classes/:id/members` (admin) - List class members
+
+
### Meeting Times
+
- `GET /api/classes/:id/meetings` - List meeting times
+
- `POST /api/classes/:id/meetings` (admin) - Create meeting time
+
- `PUT /api/meetings/:id` (admin) - Update meeting time label
+
- `DELETE /api/meetings/:id` (admin) - Delete meeting time
+
+
### Recordings/Transcripts
+
- `GET /api/classes/:id/transcripts` - List all transcripts for class
+
- `POST /api/classes/:id/recordings` - Upload recording (enrolled students)
+
- `PUT /api/transcripts/:id/select` (admin) - Mark recording for transcription
+
- `DELETE /api/transcripts/:id` - Delete recording (owner or admin)
+
- `GET /api/transcripts/:id` - View transcript (enrolled students)
+
+
## Frontend Components
+
+
### Pages
+
- `/classes` - Classes grid (home page, replaces transcripts page)
+
- `/classes/:id` - Individual class view
+
- `/admin` - Update to include class management
+
+
### New Components
+
- `class-card.ts` - Class card component
+
- `register-card.ts` - Register for class card (plus icon)
+
- `class-detail.ts` - Individual class page
+
- `recording-upload.ts` - Recording upload form
+
- `recording-list.ts` - List of recordings with status
+
- `admin-classes.ts` - Admin class management interface
+
+
### Navigation Updates
+
- Remove transcript page links
+
- Add classes link (make it home)
+
- Update auth redirect after signup to `/classes`
+
+
## Migration Strategy
+
+
**Breaking change**: Reset database schema to consolidate all migrations.
+
+
1. Export any critical production data (if needed)
+
2. Drop all tables
+
3. Consolidate migrations in `src/db/schema.ts`:
+
- Include all previous migrations
+
- Add new class system tables
+
- Add new columns to transcripts
+
4. Restart with version 1
+
5. Existing transcripts will be lost (acceptable for this phase)
+
+
## Admin UI Updates
+
+
### Class Management Tab
+
- Create new class form:
+
- Course code
+
- Course name
+
- Professor
+
- Semester dropdown (Fall/Spring/Summer/Winter)
+
- Year input
+
- List all classes (with archive status)
+
- Archive/unarchive button per class
+
- Delete class button
+
+
### Enrollment Management
+
- Search for class
+
- Add student by email
+
- Remove enrolled students
+
- View enrollment list per class
+
- Future: Bulk CSV import
+
+
### Recording Selection
+
- View pending recordings per class
+
- Select recording to transcribe for each meeting
+
- View transcription status
+
- Handle failed transcriptions
+
+
## Empty States
+
+
- **No classes**: Show only register card with message "No classes yet"
+
- **No recordings in class**: Show message "No recordings yet" with upload button
+
- **No pending recordings**: Show message in admin "All recordings processed"
+
+
## Future Enhancements (Out of Scope)
+
+
- Share/enrollment links for self-enrollment
+
- Notifications when transcripts ready
+
- Auto-transcribe settings per class
+
- Student/instructor roles
+
- Search/filter classes
+
- Bulk enrollment via CSV
+
- Meeting time templates (MWF, TTh patterns)
+
- Download all transcripts for a class
+
+
## Open Questions
+
+
None - spec is complete for initial implementation.
+
+
## Implementation Phases
+
+
### Phase 1: Database & Backend
+
1. Consolidate migrations and add new schema
+
2. Add API endpoints for classes and members
+
3. Update permissions middleware
+
4. Add admin endpoints
+
+
### Phase 2: Admin UI
+
1. Class management interface
+
2. Enrollment management
+
3. Recording selection interface
+
+
### Phase 3: Student UI
+
1. Classes page with cards
+
2. Individual class pages
+
3. Recording upload
+
4. Update navigation
+
+
### Phase 4: Testing & Polish
+
1. Test permissions thoroughly
+
2. Test archive behavior
+
3. Empty states
+
4. Error handling
+1
package.json
···
"@simplewebauthn/server": "^13.2.2",
"eventsource-client": "^1.2.0",
"lit": "^3.3.1",
+
"nanoid": "^5.1.6",
"ua-parser-js": "^2.0.6"
}
}
+54
scripts/test-classes.ts
···
+
#!/usr/bin/env bun
+
import db from "../src/db/schema";
+
import { createClass, enrollUserInClass, getMeetingTimesForClass, createMeetingTime } from "../src/lib/classes";
+
+
// Create a test user (admin)
+
const email = "admin@thistle.test";
+
const existingUser = db
+
.query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
+
.get(email);
+
+
let userId: number;
+
+
if (!existingUser) {
+
db.run(
+
"INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)",
+
[email, "test-hash", "admin"],
+
);
+
userId = db.query<{ id: number }, []>("SELECT last_insert_rowid() as id").get()!.id;
+
console.log(`✅ Created admin user: ${email} (ID: ${userId})`);
+
} else {
+
userId = existingUser.id;
+
console.log(`✅ Using existing admin user: ${email} (ID: ${userId})`);
+
}
+
+
// Create a test class
+
const cls = createClass({
+
course_code: "CS 101",
+
name: "Introduction to Computer Science",
+
professor: "Dr. Jane Smith",
+
semester: "Fall",
+
year: 2024,
+
});
+
+
console.log(`✅ Created class: ${cls.course_code} - ${cls.name} (ID: ${cls.id})`);
+
+
// Enroll the admin in the class
+
enrollUserInClass(userId, cls.id);
+
console.log(`✅ Enrolled admin in class`);
+
+
// Create meeting times
+
const meeting1 = createMeetingTime(cls.id, "Monday Lecture");
+
const meeting2 = createMeetingTime(cls.id, "Wednesday Lab");
+
console.log(`✅ Created meeting times: ${meeting1.label}, ${meeting2.label}`);
+
+
// Verify
+
const meetings = getMeetingTimesForClass(cls.id);
+
console.log(`✅ Class has ${meetings.length} meeting times`);
+
+
console.log("\n📊 Test Summary:");
+
console.log(`- Class ID: ${cls.id}`);
+
console.log(`- Course: ${cls.course_code} - ${cls.name}`);
+
console.log(`- Professor: ${cls.professor}`);
+
console.log(`- Semester: ${cls.semester} ${cls.year}`);
+
console.log(`- Meetings: ${meetings.map(m => m.label).join(", ")}`);
+232
src/db/schema.test.ts
···
+
import { Database } from "bun:sqlite";
+
import { expect, test, afterEach } from "bun:test";
+
import { unlinkSync } from "node:fs";
+
+
const TEST_DB = "test-schema.db";
+
+
afterEach(() => {
+
try {
+
unlinkSync(TEST_DB);
+
} catch {
+
// File may not exist
+
}
+
});
+
+
test("schema creates all required tables", () => {
+
const db = new Database(TEST_DB);
+
+
// Create schema_migrations table
+
db.run(`
+
CREATE TABLE IF NOT EXISTS schema_migrations (
+
version INTEGER PRIMARY KEY,
+
applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
)
+
`);
+
+
// Apply migration (simplified version of migration 1)
+
const migration = `
+
CREATE TABLE IF NOT EXISTS users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT,
+
name TEXT,
+
avatar TEXT DEFAULT 'd',
+
role TEXT NOT NULL DEFAULT 'user',
+
last_login INTEGER,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE TABLE IF NOT EXISTS classes (
+
id TEXT PRIMARY KEY,
+
course_code TEXT NOT NULL,
+
name TEXT NOT NULL,
+
professor TEXT NOT NULL,
+
semester TEXT NOT NULL,
+
year INTEGER NOT NULL,
+
archived BOOLEAN DEFAULT 0,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE TABLE IF NOT EXISTS class_members (
+
class_id TEXT NOT NULL,
+
user_id INTEGER NOT NULL,
+
enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
PRIMARY KEY (class_id, user_id),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE TABLE IF NOT EXISTS meeting_times (
+
id TEXT PRIMARY KEY,
+
class_id TEXT NOT NULL,
+
label TEXT NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
+
);
+
+
CREATE TABLE IF NOT EXISTS transcriptions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
class_id TEXT,
+
meeting_time_id TEXT,
+
filename TEXT NOT NULL,
+
original_filename TEXT NOT NULL,
+
status TEXT NOT NULL DEFAULT 'pending',
+
progress INTEGER NOT NULL DEFAULT 0,
+
error_message TEXT,
+
whisper_job_id TEXT,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL
+
);
+
`;
+
+
db.run(migration);
+
+
// Verify tables exist
+
const tables = db
+
.query<{ name: string }, []>(
+
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
+
)
+
.all();
+
+
const tableNames = tables.map((t) => t.name);
+
+
expect(tableNames).toContain("users");
+
expect(tableNames).toContain("classes");
+
expect(tableNames).toContain("class_members");
+
expect(tableNames).toContain("meeting_times");
+
expect(tableNames).toContain("transcriptions");
+
+
db.close();
+
});
+
+
test("class foreign key constraints work", () => {
+
const db = new Database(TEST_DB);
+
+
// Create tables
+
db.run(`
+
CREATE TABLE users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT,
+
role TEXT NOT NULL DEFAULT 'user',
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE TABLE classes (
+
id TEXT PRIMARY KEY,
+
course_code TEXT NOT NULL,
+
name TEXT NOT NULL,
+
professor TEXT NOT NULL,
+
semester TEXT NOT NULL,
+
year INTEGER NOT NULL,
+
archived BOOLEAN DEFAULT 0,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE TABLE class_members (
+
class_id TEXT NOT NULL,
+
user_id INTEGER NOT NULL,
+
enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
PRIMARY KEY (class_id, user_id),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
`);
+
+
db.run("PRAGMA foreign_keys = ON");
+
+
// Create test data
+
db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [
+
"test@example.com",
+
"hash",
+
]);
+
const userId = db
+
.query<{ id: number }, []>("SELECT last_insert_rowid() as id")
+
.get()?.id;
+
+
db.run(
+
"INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
+
["class-1", "CS 101", "Intro to CS", "Dr. Smith", "Fall", 2024],
+
);
+
+
// Enroll user in class
+
db.run("INSERT INTO class_members (class_id, user_id) VALUES (?, ?)", [
+
"class-1",
+
userId,
+
]);
+
+
const enrollment = db
+
.query<
+
{ class_id: string; user_id: number },
+
[]
+
>("SELECT class_id, user_id FROM class_members")
+
.get();
+
+
expect(enrollment?.class_id).toBe("class-1");
+
expect(enrollment?.user_id).toBe(userId);
+
+
// Delete class should cascade delete enrollment
+
db.run("DELETE FROM classes WHERE id = ?", ["class-1"]);
+
+
const enrollments = db
+
.query<{ class_id: string }, []>("SELECT class_id FROM class_members")
+
.all();
+
expect(enrollments.length).toBe(0);
+
+
db.close();
+
});
+
+
test("transcription status defaults to pending", () => {
+
const db = new Database(TEST_DB);
+
+
db.run(`
+
CREATE TABLE users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT
+
);
+
+
CREATE TABLE transcriptions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
class_id TEXT,
+
meeting_time_id TEXT,
+
filename TEXT NOT NULL,
+
original_filename TEXT NOT NULL,
+
status TEXT NOT NULL DEFAULT 'pending',
+
progress INTEGER NOT NULL DEFAULT 0,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
`);
+
+
db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [
+
"test@example.com",
+
"hash",
+
]);
+
const userId = db
+
.query<{ id: number }, []>("SELECT last_insert_rowid() as id")
+
.get()?.id;
+
+
db.run(
+
"INSERT INTO transcriptions (id, user_id, filename, original_filename) VALUES (?, ?, ?, ?)",
+
["trans-1", userId, "file.mp3", "original.mp3"],
+
);
+
+
const transcription = db
+
.query<
+
{ status: string; progress: number },
+
[]
+
>("SELECT status, progress FROM transcriptions WHERE id = 'trans-1'")
+
.get();
+
+
expect(transcription?.status).toBe("pending");
+
expect(transcription?.progress).toBe(0);
+
+
db.close();
+
});
+75 -95
src/db/schema.ts
···
const migrations = [
{
version: 1,
-
name: "Complete user schema",
+
name: "Complete schema with class system",
sql: `
+
-- Users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
-
password_hash TEXT NOT NULL,
+
password_hash TEXT,
name TEXT,
avatar TEXT DEFAULT 'd',
+
role TEXT NOT NULL DEFAULT 'user',
+
last_login INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
+
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
+
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login);
+
+
-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
···
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
-
`,
-
},
-
{
-
version: 2,
-
name: "Add transcriptions table",
-
sql: `
-
CREATE TABLE IF NOT EXISTS transcriptions (
+
+
-- Passkeys table
+
CREATE TABLE IF NOT EXISTS passkeys (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
-
filename TEXT NOT NULL,
-
original_filename TEXT NOT NULL,
-
status TEXT NOT NULL DEFAULT 'uploading',
-
progress INTEGER NOT NULL DEFAULT 0,
-
transcript TEXT,
-
error_message TEXT,
+
credential_id TEXT NOT NULL UNIQUE,
+
public_key TEXT NOT NULL,
+
counter INTEGER NOT NULL DEFAULT 0,
+
transports TEXT,
+
name TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
-
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
last_used_at INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-
CREATE INDEX IF NOT EXISTS idx_transcriptions_user_id ON transcriptions(user_id);
-
CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status);
-
`,
-
},
-
{
-
version: 3,
-
name: "Add whisper_job_id to transcriptions",
-
sql: `
-
ALTER TABLE transcriptions ADD COLUMN whisper_job_id TEXT;
-
CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id);
-
`,
-
},
-
{
-
version: 4,
-
name: "Remove transcript column from transcriptions",
-
sql: `
-
-- SQLite 3.35.0+ supports DROP COLUMN
-
ALTER TABLE transcriptions DROP COLUMN transcript;
-
`,
-
},
-
{
-
version: 5,
-
name: "Add rate limiting table",
-
sql: `
+
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id);
+
CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id);
+
+
-- Rate limiting table
CREATE TABLE IF NOT EXISTS rate_limit_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
···
);
CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp);
-
`,
-
},
-
{
-
version: 6,
-
name: "Add role-based auth system",
-
sql: `
-
-- Add role column (default to 'user')
-
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
-
-
-- Create index on role
-
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
-
`,
-
},
-
{
-
version: 7,
-
name: "Add WebAuthn passkey support",
-
sql: `
-
CREATE TABLE IF NOT EXISTS passkeys (
+
+
-- Classes table
+
CREATE TABLE IF NOT EXISTS classes (
id TEXT PRIMARY KEY,
+
course_code TEXT NOT NULL,
+
name TEXT NOT NULL,
+
professor TEXT NOT NULL,
+
semester TEXT NOT NULL,
+
year INTEGER NOT NULL,
+
archived BOOLEAN DEFAULT 0,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_classes_semester_year ON classes(semester, year);
+
CREATE INDEX IF NOT EXISTS idx_classes_archived ON classes(archived);
+
+
-- Class members table
+
CREATE TABLE IF NOT EXISTS class_members (
+
class_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
-
credential_id TEXT NOT NULL UNIQUE,
-
public_key TEXT NOT NULL,
-
counter INTEGER NOT NULL DEFAULT 0,
-
transports TEXT,
-
name TEXT,
-
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
-
last_used_at INTEGER,
+
enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
PRIMARY KEY (class_id, user_id),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id);
-
CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id);
+
CREATE INDEX IF NOT EXISTS idx_class_members_user_id ON class_members(user_id);
+
CREATE INDEX IF NOT EXISTS idx_class_members_class_id ON class_members(class_id);
-
-- Make password optional for users who only use passkeys
-
CREATE TABLE users_new (
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
-
email TEXT UNIQUE NOT NULL,
-
password_hash TEXT,
-
name TEXT,
-
avatar TEXT DEFAULT 'd',
+
-- Meeting times table
+
CREATE TABLE IF NOT EXISTS meeting_times (
+
id TEXT PRIMARY KEY,
+
class_id TEXT NOT NULL,
+
label TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
-
role TEXT NOT NULL DEFAULT 'user'
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
);
-
INSERT INTO users_new SELECT * FROM users;
-
DROP TABLE users;
-
ALTER TABLE users_new RENAME TO users;
+
CREATE INDEX IF NOT EXISTS idx_meeting_times_class_id ON meeting_times(class_id);
+
+
-- Transcriptions table
+
CREATE TABLE IF NOT EXISTS transcriptions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
class_id TEXT,
+
meeting_time_id TEXT,
+
filename TEXT NOT NULL,
+
original_filename TEXT NOT NULL,
+
status TEXT NOT NULL DEFAULT 'pending',
+
progress INTEGER NOT NULL DEFAULT 0,
+
error_message TEXT,
+
whisper_job_id TEXT,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL
+
);
-
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
-
`,
-
},
-
{
-
version: 8,
-
name: "Add last_login to users",
-
sql: `
-
ALTER TABLE users ADD COLUMN last_login INTEGER;
-
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login);
-
`,
-
},
-
{
-
version: 9,
-
name: "Add class_name to transcriptions",
-
sql: `
-
ALTER TABLE transcriptions ADD COLUMN class_name TEXT;
-
CREATE INDEX IF NOT EXISTS idx_transcriptions_class_name ON transcriptions(class_name);
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_user_id ON transcriptions(user_id);
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_class_id ON transcriptions(class_id);
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status);
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id);
`,
},
];
+351 -26
src/index.ts
···
getSession,
getSessionFromRequest,
getSessionsForUser,
+
getUserByEmail,
getUserBySession,
getUserSessionsForUser,
type UserRole,
···
updateUserPassword,
updateUserRole,
} from "./lib/auth";
+
import {
+
createClass,
+
createMeetingTime,
+
deleteClass,
+
deleteMeetingTime,
+
enrollUserInClass,
+
getClassById,
+
getClassesForUser,
+
getClassMembers,
+
getMeetingTimesForClass,
+
getTranscriptionsForClass,
+
isUserEnrolledInClass,
+
removeUserFromClass,
+
toggleClassArchive,
+
updateMeetingTime,
+
} from "./lib/classes";
import { handleError, ValidationErrors } from "./lib/errors";
import { requireAdmin, requireAuth } from "./lib/middleware";
import {
···
id: string;
filename: string;
original_filename: string;
-
class_name: string | null;
+
class_id: string | null;
status: string;
progress: number;
created_at: number;
},
[number]
>(
-
"SELECT id, filename, original_filename, class_name, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
+
"SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
)
.all(user.id);
···
return {
id: t.id,
filename: t.original_filename,
-
class_name: t.class_name,
+
class_id: t.class_id,
status: t.status,
progress: t.progress,
created_at: t.created_at,
···
const formData = await req.formData();
const file = formData.get("audio") as File;
-
const className = formData.get("class_name") as string | null;
+
const classId = formData.get("class_id") as string | null;
+
const meetingTimeId = formData.get("meeting_time_id") as string | null;
if (!file) throw ValidationErrors.missingField("audio");
+
// If class_id provided, verify user is enrolled (or admin)
+
if (classId) {
+
const enrolled = isUserEnrolledInClass(user.id, classId);
+
if (!enrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
// Verify class exists
+
const classInfo = getClassById(classId);
+
if (!classInfo) {
+
return Response.json(
+
{ error: "Class not found" },
+
{ status: 404 },
+
);
+
}
+
+
// Check if class is archived
+
if (classInfo.archived) {
+
return Response.json(
+
{ error: "Cannot upload to archived class" },
+
{ status: 400 },
+
);
+
}
+
}
+
// Validate file type
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = [
···
const uploadDir = "./uploads";
await Bun.write(`${uploadDir}/${filename}`, file);
-
// Create database record with optional class_name
-
if (className?.trim()) {
-
db.run(
-
"INSERT INTO transcriptions (id, user_id, filename, original_filename, class_name, status) VALUES (?, ?, ?, ?, ?, ?)",
-
[
-
transcriptionId,
-
user.id,
-
filename,
-
file.name,
-
className.trim(),
-
"uploading",
-
],
-
);
-
} else {
-
db.run(
-
"INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)",
-
[transcriptionId, user.id, filename, file.name, "uploading"],
-
);
-
}
+
// Create database record
+
db.run(
+
"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
+
[
+
transcriptionId,
+
user.id,
+
classId,
+
meetingTimeId,
+
filename,
+
file.name,
+
"pending",
+
],
+
);
-
// Start transcription in background
-
whisperService.startTranscription(transcriptionId, filename);
+
// Don't auto-start transcription - admin will select recordings
+
// whisperService.startTranscription(transcriptionId, filename);
return Response.json({
id: transcriptionId,
-
message: "Upload successful, transcription started",
+
message: "Upload successful",
});
} catch (error) {
return handleError(error);
···
user_email: user?.email || "Unknown",
user_name: user?.name || null,
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes": {
+
GET: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const classes = getClassesForUser(user.id, user.role === "admin");
+
+
// Group by semester/year
+
const grouped: Record<
+
string,
+
Array<{
+
id: string;
+
course_code: string;
+
name: string;
+
professor: string;
+
semester: string;
+
year: number;
+
archived: boolean;
+
}>
+
> = {};
+
+
for (const cls of classes) {
+
const key = `${cls.semester} ${cls.year}`;
+
if (!grouped[key]) {
+
grouped[key] = [];
+
}
+
grouped[key]?.push({
+
id: cls.id,
+
course_code: cls.course_code,
+
name: cls.name,
+
professor: cls.professor,
+
semester: cls.semester,
+
year: cls.year,
+
archived: cls.archived,
+
});
+
}
+
+
return Response.json({ classes: grouped });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
POST: async (req) => {
+
try {
+
requireAdmin(req);
+
const body = await req.json();
+
const { course_code, name, professor, semester, year } = body;
+
+
if (!course_code || !name || !professor || !semester || !year) {
+
return Response.json(
+
{ error: "Missing required fields" },
+
{ status: 400 },
+
);
+
}
+
+
const newClass = createClass({
+
course_code,
+
name,
+
professor,
+
semester,
+
year,
+
});
+
+
return Response.json(newClass);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:id": {
+
GET: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const classId = req.params.id;
+
+
const classInfo = getClassById(classId);
+
if (!classInfo) {
+
return Response.json({ error: "Class not found" }, { status: 404 });
+
}
+
+
// Check enrollment or admin
+
const isEnrolled = isUserEnrolledInClass(user.id, classId);
+
if (!isEnrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
const meetingTimes = getMeetingTimesForClass(classId);
+
const transcriptions = getTranscriptionsForClass(classId);
+
+
return Response.json({
+
class: classInfo,
+
meetingTimes,
+
transcriptions,
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
+
deleteClass(classId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:id/archive": {
+
PUT: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
const body = await req.json();
+
const { archived } = body;
+
+
if (typeof archived !== "boolean") {
+
return Response.json(
+
{ error: "archived must be a boolean" },
+
{ status: 400 },
+
);
+
}
+
+
toggleClassArchive(classId, archived);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:id/members": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
+
const members = getClassMembers(classId);
+
return Response.json({ members });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
POST: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
const body = await req.json();
+
const { email } = body;
+
+
if (!email) {
+
return Response.json({ error: "Email required" }, { status: 400 });
+
}
+
+
const user = getUserByEmail(email);
+
if (!user) {
+
return Response.json({ error: "User not found" }, { status: 404 });
+
}
+
+
enrollUserInClass(user.id, classId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:id/members/:userId": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
const userId = Number.parseInt(req.params.userId, 10);
+
+
if (Number.isNaN(userId)) {
+
return Response.json({ error: "Invalid user ID" }, { status: 400 });
+
}
+
+
removeUserFromClass(userId, classId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/:id/meetings": {
+
GET: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const classId = req.params.id;
+
+
// Check enrollment or admin
+
const isEnrolled = isUserEnrolledInClass(user.id, classId);
+
if (!isEnrolled && user.role !== "admin") {
+
return Response.json(
+
{ error: "Not enrolled in this class" },
+
{ status: 403 },
+
);
+
}
+
+
const meetingTimes = getMeetingTimesForClass(classId);
+
return Response.json({ meetings: meetingTimes });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
POST: async (req) => {
+
try {
+
requireAdmin(req);
+
const classId = req.params.id;
+
const body = await req.json();
+
const { label } = body;
+
+
if (!label) {
+
return Response.json({ error: "Label required" }, { status: 400 });
+
}
+
+
const meetingTime = createMeetingTime(classId, label);
+
return Response.json(meetingTime);
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/meetings/:id": {
+
PUT: async (req) => {
+
try {
+
requireAdmin(req);
+
const meetingId = req.params.id;
+
const body = await req.json();
+
const { label } = body;
+
+
if (!label) {
+
return Response.json({ error: "Label required" }, { status: 400 });
+
}
+
+
updateMeetingTime(meetingId, label);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const meetingId = req.params.id;
+
+
deleteMeetingTime(meetingId);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/transcripts/:id/select": {
+
PUT: async (req) => {
+
try {
+
requireAdmin(req);
+
const transcriptId = req.params.id;
+
+
// Update status to 'selected' and start transcription
+
db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
+
"selected",
+
transcriptId,
+
]);
+
+
// Get filename to start transcription
+
const transcription = db
+
.query<{ filename: string }, [string]>(
+
"SELECT filename FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptId);
+
+
if (transcription) {
+
whisperService.startTranscription(transcriptId, transcription.filename);
+
}
+
+
return Response.json({ success: true });
} catch (error) {
return handleError(error);
+10
src/lib/auth.ts
···
return user ?? null;
}
+
export function getUserByEmail(email: string): User | null {
+
const user = db
+
.query<User, [string]>(
+
"SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE email = ?",
+
)
+
.get(email);
+
+
return user ?? null;
+
}
+
export function deleteSession(sessionId: string): void {
db.run("DELETE FROM sessions WHERE id = ?", [sessionId]);
}
+187
src/lib/classes.test.ts
···
+
import { afterEach, beforeEach, expect, test } from "bun:test";
+
import { unlinkSync } from "node:fs";
+
import {
+
createClass,
+
createMeetingTime,
+
enrollUserInClass,
+
getClassById,
+
getClassesForUser,
+
getMeetingTimesForClass,
+
getTranscriptionsForClass,
+
isUserEnrolledInClass,
+
} from "./classes";
+
import { Database } from "bun:sqlite";
+
+
const TEST_DB = "test-classes.db";
+
let db: Database;
+
+
beforeEach(() => {
+
db = new Database(TEST_DB);
+
+
// Create minimal schema for testing
+
db.run(`
+
CREATE TABLE users (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT,
+
role TEXT NOT NULL DEFAULT 'user',
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE TABLE classes (
+
id TEXT PRIMARY KEY,
+
course_code TEXT NOT NULL,
+
name TEXT NOT NULL,
+
professor TEXT NOT NULL,
+
semester TEXT NOT NULL,
+
year INTEGER NOT NULL,
+
archived BOOLEAN DEFAULT 0,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
+
);
+
+
CREATE TABLE class_members (
+
class_id TEXT NOT NULL,
+
user_id INTEGER NOT NULL,
+
enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
PRIMARY KEY (class_id, user_id),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE TABLE meeting_times (
+
id TEXT PRIMARY KEY,
+
class_id TEXT NOT NULL,
+
label TEXT NOT NULL,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
+
);
+
+
CREATE TABLE transcriptions (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
class_id TEXT,
+
meeting_time_id TEXT,
+
filename TEXT NOT NULL,
+
original_filename TEXT NOT NULL,
+
status TEXT NOT NULL DEFAULT 'pending',
+
progress INTEGER NOT NULL DEFAULT 0,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
+
FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL
+
);
+
`);
+
});
+
+
afterEach(() => {
+
db.close();
+
try {
+
unlinkSync(TEST_DB);
+
} catch {
+
// File may not exist
+
}
+
});
+
+
test("creates a class with all required fields", () => {
+
const cls = createClass({
+
course_code: "CS 101",
+
name: "Intro to CS",
+
professor: "Dr. Smith",
+
semester: "Fall",
+
year: 2024,
+
});
+
+
expect(cls.id).toBeTruthy();
+
expect(cls.course_code).toBe("CS 101");
+
expect(cls.name).toBe("Intro to CS");
+
expect(cls.professor).toBe("Dr. Smith");
+
expect(cls.semester).toBe("Fall");
+
expect(cls.year).toBe(2024);
+
expect(cls.archived).toBe(false);
+
});
+
+
test("enrolls user in class", () => {
+
// Create user
+
db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [
+
"test@example.com",
+
"hash",
+
]);
+
const userId = db
+
.query<{ id: number }, []>("SELECT last_insert_rowid() as id")
+
.get()?.id;
+
+
// Create class
+
const cls = createClass({
+
course_code: "CS 101",
+
name: "Intro to CS",
+
professor: "Dr. Smith",
+
semester: "Fall",
+
year: 2024,
+
});
+
+
// Enroll user
+
enrollUserInClass(userId!, cls.id);
+
+
// Verify enrollment
+
const isEnrolled = isUserEnrolledInClass(userId!, cls.id);
+
expect(isEnrolled).toBe(true);
+
});
+
+
test("gets classes for enrolled user", () => {
+
// Create user
+
db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [
+
"test@example.com",
+
"hash",
+
]);
+
const userId = db
+
.query<{ id: number }, []>("SELECT last_insert_rowid() as id")
+
.get()?.id;
+
+
// Create two classes
+
const cls1 = createClass({
+
course_code: "CS 101",
+
name: "Intro to CS",
+
professor: "Dr. Smith",
+
semester: "Fall",
+
year: 2024,
+
});
+
+
const cls2 = createClass({
+
course_code: "CS 102",
+
name: "Data Structures",
+
professor: "Dr. Jones",
+
semester: "Fall",
+
year: 2024,
+
});
+
+
// Enroll user in only one class
+
enrollUserInClass(userId!, cls1.id);
+
+
// Get classes for user
+
const classes = getClassesForUser(userId!, false);
+
expect(classes.length).toBe(1);
+
expect(classes[0]?.id).toBe(cls1.id);
+
+
// Admin should see all
+
const allClasses = getClassesForUser(userId!, true);
+
expect(allClasses.length).toBe(2);
+
});
+
+
test("creates and retrieves meeting times", () => {
+
const cls = createClass({
+
course_code: "CS 101",
+
name: "Intro to CS",
+
professor: "Dr. Smith",
+
semester: "Fall",
+
year: 2024,
+
});
+
+
const meeting1 = createMeetingTime(cls.id, "Monday Lecture");
+
const meeting2 = createMeetingTime(cls.id, "Wednesday Lab");
+
+
const meetings = getMeetingTimesForClass(cls.id);
+
expect(meetings.length).toBe(2);
+
expect(meetings[0]?.label).toBe("Monday Lecture");
+
expect(meetings[1]?.label).toBe("Wednesday Lab");
+
});
+249
src/lib/classes.ts
···
+
import { nanoid } from "nanoid";
+
import db from "../db/schema";
+
+
export interface Class {
+
id: string;
+
course_code: string;
+
name: string;
+
professor: string;
+
semester: string;
+
year: number;
+
archived: boolean;
+
created_at: number;
+
}
+
+
export interface MeetingTime {
+
id: string;
+
class_id: string;
+
label: string;
+
created_at: number;
+
}
+
+
export interface ClassMember {
+
class_id: string;
+
user_id: number;
+
enrolled_at: number;
+
}
+
+
/**
+
* Get all classes for a user (either enrolled or admin sees all)
+
*/
+
export function getClassesForUser(
+
userId: number,
+
isAdmin: boolean,
+
): Class[] {
+
if (isAdmin) {
+
return db
+
.query<Class, []>(
+
"SELECT * FROM classes ORDER BY year DESC, semester DESC, course_code ASC",
+
)
+
.all();
+
}
+
+
return db
+
.query<Class, [number]>(
+
`SELECT c.* FROM classes c
+
INNER JOIN class_members cm ON c.id = cm.class_id
+
WHERE cm.user_id = ?
+
ORDER BY c.year DESC, c.semester DESC, c.course_code ASC`,
+
)
+
.all(userId);
+
}
+
+
/**
+
* Get a single class by ID
+
*/
+
export function getClassById(classId: string): Class | null {
+
const result = db
+
.query<Class, [string]>("SELECT * FROM classes WHERE id = ?")
+
.get(classId);
+
return result ?? null;
+
}
+
+
/**
+
* Check if user is enrolled in a class
+
*/
+
export function isUserEnrolledInClass(
+
userId: number,
+
classId: string,
+
): boolean {
+
const result = db
+
.query<{ count: number }, [string, number]>(
+
"SELECT COUNT(*) as count FROM class_members WHERE class_id = ? AND user_id = ?",
+
)
+
.get(classId, userId);
+
return (result?.count ?? 0) > 0;
+
}
+
+
/**
+
* Create a new class
+
*/
+
export function createClass(data: {
+
course_code: string;
+
name: string;
+
professor: string;
+
semester: string;
+
year: number;
+
}): Class {
+
const id = nanoid();
+
const now = Math.floor(Date.now() / 1000);
+
+
db.run(
+
"INSERT INTO classes (id, course_code, name, professor, semester, year, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
+
[
+
id,
+
data.course_code,
+
data.name,
+
data.professor,
+
data.semester,
+
data.year,
+
now,
+
],
+
);
+
+
return {
+
id,
+
course_code: data.course_code,
+
name: data.name,
+
professor: data.professor,
+
semester: data.semester,
+
year: data.year,
+
archived: false,
+
created_at: now,
+
};
+
}
+
+
/**
+
* Archive or unarchive a class
+
*/
+
export function toggleClassArchive(classId: string, archived: boolean): void {
+
db.run("UPDATE classes SET archived = ? WHERE id = ?", [
+
archived ? 1 : 0,
+
classId,
+
]);
+
}
+
+
/**
+
* Delete a class (cascades to members, meeting times, and transcriptions)
+
*/
+
export function deleteClass(classId: string): void {
+
db.run("DELETE FROM classes WHERE id = ?", [classId]);
+
}
+
+
/**
+
* Enroll a user in a class
+
*/
+
export function enrollUserInClass(userId: number, classId: string): void {
+
const now = Math.floor(Date.now() / 1000);
+
db.run(
+
"INSERT OR IGNORE INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
+
[classId, userId, now],
+
);
+
}
+
+
/**
+
* Remove a user from a class
+
*/
+
export function removeUserFromClass(userId: number, classId: string): void {
+
db.run("DELETE FROM class_members WHERE class_id = ? AND user_id = ?", [
+
classId,
+
userId,
+
]);
+
}
+
+
/**
+
* Get all members of a class
+
*/
+
export function getClassMembers(classId: string) {
+
return db
+
.query<
+
{
+
user_id: number;
+
email: string;
+
name: string | null;
+
avatar: string;
+
enrolled_at: number;
+
},
+
[string]
+
>(
+
`SELECT cm.user_id, u.email, u.name, u.avatar, cm.enrolled_at
+
FROM class_members cm
+
INNER JOIN users u ON cm.user_id = u.id
+
WHERE cm.class_id = ?
+
ORDER BY cm.enrolled_at DESC`,
+
)
+
.all(classId);
+
}
+
+
/**
+
* Create a meeting time for a class
+
*/
+
export function createMeetingTime(classId: string, label: string): MeetingTime {
+
const id = nanoid();
+
const now = Math.floor(Date.now() / 1000);
+
+
db.run(
+
"INSERT INTO meeting_times (id, class_id, label, created_at) VALUES (?, ?, ?, ?)",
+
[id, classId, label, now],
+
);
+
+
return {
+
id,
+
class_id: classId,
+
label,
+
created_at: now,
+
};
+
}
+
+
/**
+
* Get all meeting times for a class
+
*/
+
export function getMeetingTimesForClass(classId: string): MeetingTime[] {
+
return db
+
.query<MeetingTime, [string]>(
+
"SELECT * FROM meeting_times WHERE class_id = ? ORDER BY created_at ASC",
+
)
+
.all(classId);
+
}
+
+
/**
+
* Update a meeting time label
+
*/
+
export function updateMeetingTime(meetingId: string, label: string): void {
+
db.run("UPDATE meeting_times SET label = ? WHERE id = ?", [label, meetingId]);
+
}
+
+
/**
+
* Delete a meeting time
+
*/
+
export function deleteMeetingTime(meetingId: string): void {
+
db.run("DELETE FROM meeting_times WHERE id = ?", [meetingId]);
+
}
+
+
/**
+
* Get transcriptions for a class
+
*/
+
export function getTranscriptionsForClass(classId: string) {
+
return db
+
.query<
+
{
+
id: string;
+
user_id: number;
+
meeting_time_id: string | null;
+
filename: string;
+
original_filename: string;
+
status: string;
+
progress: number;
+
error_message: string | null;
+
created_at: number;
+
updated_at: number;
+
},
+
[string]
+
>(
+
`SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at
+
FROM transcriptions
+
WHERE class_id = ?
+
ORDER BY created_at DESC`,
+
)
+
.all(classId);
+
}