馃 distributed transcription service
thistle.dunkirk.sh
1import { nanoid } from "nanoid";
2import db from "../db/schema";
3
4export interface Class {
5 id: string;
6 course_code: string;
7 name: string;
8 professor: string;
9 semester: string;
10 year: number;
11 archived: boolean;
12 created_at: number;
13}
14
15export interface MeetingTime {
16 id: string;
17 class_id: string;
18 label: string;
19 created_at: number;
20}
21
22export interface ClassMember {
23 class_id: string;
24 user_id: number;
25 enrolled_at: number;
26}
27
28export interface ClassWithStats extends Class {
29 student_count?: number;
30 transcript_count?: number;
31}
32
33/**
34 * Get all classes for a user (either enrolled or admin sees all)
35 */
36export function getClassesForUser(
37 userId: number,
38 isAdmin: boolean,
39 limit = 50,
40 cursor?: string,
41): {
42 data: ClassWithStats[];
43 pagination: {
44 limit: number;
45 hasMore: boolean;
46 nextCursor: string | null;
47 };
48} {
49 let classes: ClassWithStats[];
50
51 if (isAdmin) {
52 if (cursor) {
53 const { decodeClassCursor } = require("./cursor");
54 const { year, semester, courseCode, id } = decodeClassCursor(cursor);
55
56 classes = db
57 .query<ClassWithStats, [number, string, string, string, number]>(
58 `SELECT
59 c.*,
60 (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count,
61 (SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count
62 FROM classes c
63 WHERE (c.year < ? OR
64 (c.year = ? AND c.semester < ?) OR
65 (c.year = ? AND c.semester = ? AND c.course_code > ?) OR
66 (c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?))
67 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
68 LIMIT ?`,
69 )
70 .all(
71 year,
72 year,
73 semester,
74 year,
75 semester,
76 courseCode,
77 year,
78 semester,
79 courseCode,
80 id,
81 limit + 1,
82 );
83 } else {
84 classes = db
85 .query<ClassWithStats, [number]>(
86 `SELECT
87 c.*,
88 (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count,
89 (SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count
90 FROM classes c
91 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
92 LIMIT ?`,
93 )
94 .all(limit + 1);
95 }
96 } else {
97 if (cursor) {
98 const { decodeClassCursor } = require("./cursor");
99 const { year, semester, courseCode, id } = decodeClassCursor(cursor);
100
101 classes = db
102 .query<ClassWithStats, [number, number, string, string, string, number]>(
103 `SELECT c.* FROM classes c
104 INNER JOIN class_members cm ON c.id = cm.class_id
105 WHERE cm.user_id = ? AND
106 (c.year < ? OR
107 (c.year = ? AND c.semester < ?) OR
108 (c.year = ? AND c.semester = ? AND c.course_code > ?) OR
109 (c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?))
110 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
111 LIMIT ?`,
112 )
113 .all(
114 userId,
115 year,
116 year,
117 semester,
118 year,
119 semester,
120 courseCode,
121 year,
122 semester,
123 courseCode,
124 id,
125 limit + 1,
126 );
127 } else {
128 classes = db
129 .query<ClassWithStats, [number, number]>(
130 `SELECT c.* FROM classes c
131 INNER JOIN class_members cm ON c.id = cm.class_id
132 WHERE cm.user_id = ?
133 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC
134 LIMIT ?`,
135 )
136 .all(userId, limit + 1);
137 }
138 }
139
140 const hasMore = classes.length > limit;
141 if (hasMore) {
142 classes.pop();
143 }
144
145 let nextCursor: string | null = null;
146 if (hasMore && classes.length > 0) {
147 const { encodeClassCursor } = require("./cursor");
148 const last = classes[classes.length - 1];
149 if (last) {
150 nextCursor = encodeClassCursor(
151 last.year,
152 last.semester,
153 last.course_code,
154 last.id,
155 );
156 }
157 }
158
159 return {
160 data: classes,
161 pagination: {
162 limit,
163 hasMore,
164 nextCursor,
165 },
166 };
167}
168
169/**
170 * Get a single class by ID
171 */
172export function getClassById(classId: string): Class | null {
173 const result = db
174 .query<Class, [string]>("SELECT * FROM classes WHERE id = ?")
175 .get(classId);
176 return result ?? null;
177}
178
179/**
180 * Check if user is enrolled in a class
181 */
182export function isUserEnrolledInClass(
183 userId: number,
184 classId: string,
185): boolean {
186 const result = db
187 .query<{ count: number }, [string, number]>(
188 "SELECT COUNT(*) as count FROM class_members WHERE class_id = ? AND user_id = ?",
189 )
190 .get(classId, userId);
191 return (result?.count ?? 0) > 0;
192}
193
194/**
195 * Create a new class
196 */
197export function createClass(data: {
198 course_code: string;
199 name: string;
200 professor: string;
201 semester: string;
202 year: number;
203 meeting_times?: string[];
204}): Class {
205 const id = nanoid();
206 const now = Math.floor(Date.now() / 1000);
207
208 db.run(
209 "INSERT INTO classes (id, course_code, name, professor, semester, year, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
210 [
211 id,
212 data.course_code,
213 data.name,
214 data.professor,
215 data.semester,
216 data.year,
217 now,
218 ],
219 );
220
221 // Create meeting times if provided
222 if (data.meeting_times && data.meeting_times.length > 0) {
223 for (const label of data.meeting_times) {
224 createMeetingTime(id, label);
225 }
226 }
227
228 return {
229 id,
230 course_code: data.course_code,
231 name: data.name,
232 professor: data.professor,
233 semester: data.semester,
234 year: data.year,
235 archived: false,
236 created_at: now,
237 };
238}
239
240/**
241 * Archive or unarchive a class
242 */
243export function toggleClassArchive(classId: string, archived: boolean): void {
244 const result = db.run("UPDATE classes SET archived = ? WHERE id = ?", [
245 archived ? 1 : 0,
246 classId,
247 ]);
248
249 if (result.changes === 0) {
250 throw new Error("Class not found");
251 }
252}
253
254/**
255 * Delete a class (cascades to members, meeting times, and transcriptions)
256 */
257export function deleteClass(classId: string): void {
258 db.run("DELETE FROM classes WHERE id = ?", [classId]);
259}
260
261/**
262 * Enroll a user in a class
263 */
264export function enrollUserInClass(userId: number, classId: string): void {
265 const now = Math.floor(Date.now() / 1000);
266 db.run(
267 "INSERT OR IGNORE INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
268 [classId, userId, now],
269 );
270}
271
272/**
273 * Remove a user from a class
274 */
275export function removeUserFromClass(userId: number, classId: string): void {
276 db.run("DELETE FROM class_members WHERE class_id = ? AND user_id = ?", [
277 classId,
278 userId,
279 ]);
280}
281
282/**
283 * Get all members of a class
284 */
285export function getClassMembers(classId: string) {
286 return db
287 .query<
288 {
289 user_id: number;
290 email: string;
291 name: string | null;
292 avatar: string;
293 enrolled_at: number;
294 },
295 [string]
296 >(
297 `SELECT cm.user_id, u.email, u.name, u.avatar, cm.enrolled_at
298 FROM class_members cm
299 INNER JOIN users u ON cm.user_id = u.id
300 WHERE cm.class_id = ?
301 ORDER BY cm.enrolled_at DESC`,
302 )
303 .all(classId);
304}
305
306/**
307 * Create a meeting time for a class
308 */
309export function createMeetingTime(classId: string, label: string): MeetingTime {
310 const id = nanoid();
311 const now = Math.floor(Date.now() / 1000);
312
313 db.run(
314 "INSERT INTO meeting_times (id, class_id, label, created_at) VALUES (?, ?, ?, ?)",
315 [id, classId, label, now],
316 );
317
318 return {
319 id,
320 class_id: classId,
321 label,
322 created_at: now,
323 };
324}
325
326/**
327 * Get all meeting times for a class
328 */
329export function getMeetingTimesForClass(classId: string): MeetingTime[] {
330 return db
331 .query<MeetingTime, [string]>(
332 "SELECT * FROM meeting_times WHERE class_id = ? ORDER BY created_at ASC",
333 )
334 .all(classId);
335}
336
337/**
338 * Update a meeting time label
339 */
340export function updateMeetingTime(meetingId: string, label: string): void {
341 db.run("UPDATE meeting_times SET label = ? WHERE id = ?", [label, meetingId]);
342}
343
344/**
345 * Delete a meeting time
346 */
347export function deleteMeetingTime(meetingId: string): void {
348 db.run("DELETE FROM meeting_times WHERE id = ?", [meetingId]);
349}
350
351/**
352 * Get transcriptions for a class
353 */
354export function getTranscriptionsForClass(classId: string) {
355 return db
356 .query<
357 {
358 id: string;
359 user_id: number;
360 meeting_time_id: string | null;
361 filename: string;
362 original_filename: string;
363 status: string;
364 progress: number;
365 error_message: string | null;
366 created_at: number;
367 updated_at: number;
368 },
369 [string]
370 >(
371 `SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at
372 FROM transcriptions
373 WHERE class_id = ?
374 ORDER BY created_at DESC`,
375 )
376 .all(classId);
377}
378
379/**
380 * Search for classes by course code
381 */
382export function searchClassesByCourseCode(courseCode: string): Class[] {
383 return db
384 .query<Class, [string]>(
385 `SELECT * FROM classes
386 WHERE UPPER(course_code) LIKE UPPER(?)
387 AND archived = 0
388 ORDER BY year DESC, semester DESC, professor ASC`,
389 )
390 .all(`%${courseCode}%`);
391}
392
393/**
394 * Join a class by class ID
395 */
396export function joinClass(
397 classId: string,
398 userId: number,
399): { success: boolean; error?: string } {
400 // Find class by ID
401 const cls = db
402 .query<Class, [string]>("SELECT * FROM classes WHERE id = ?")
403 .get(classId);
404
405 if (!cls) {
406 return { success: false, error: "Class not found" };
407 }
408
409 if (cls.archived) {
410 return { success: false, error: "This class is archived" };
411 }
412
413 // Check if already enrolled
414 const existing = db
415 .query<ClassMember, [string, number]>(
416 "SELECT * FROM class_members WHERE class_id = ? AND user_id = ?",
417 )
418 .get(cls.id, userId);
419
420 if (existing) {
421 return { success: false, error: "Already enrolled in this class" };
422 }
423
424 // Enroll user
425 db.query(
426 "INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
427 ).run(cls.id, userId, Math.floor(Date.now() / 1000));
428
429 return { success: true };
430}
431
432/**
433 * Waitlist entry interface
434 */
435export interface WaitlistEntry {
436 id: string;
437 user_id: number;
438 course_code: string;
439 course_name: string;
440 professor: string;
441 semester: string;
442 year: number;
443 additional_info: string | null;
444 meeting_times: string | null;
445 created_at: number;
446}
447
448/**
449 * Add a class to the waitlist
450 */
451export function addToWaitlist(
452 userId: number,
453 courseCode: string,
454 courseName: string,
455 professor: string,
456 semester: string,
457 year: number,
458 additionalInfo: string | null,
459 meetingTimes: string[] | null,
460): string {
461 const id = nanoid();
462 const meetingTimesJson = meetingTimes ? JSON.stringify(meetingTimes) : null;
463
464 db.query(
465 `INSERT INTO class_waitlist
466 (id, user_id, course_code, course_name, professor, semester, year, additional_info, meeting_times, created_at)
467 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
468 ).run(
469 id,
470 userId,
471 courseCode,
472 courseName,
473 professor,
474 semester,
475 year,
476 additionalInfo,
477 meetingTimesJson,
478 Math.floor(Date.now() / 1000),
479 );
480 return id;
481}
482
483/**
484 * Get all waitlist entries
485 */
486export function getAllWaitlistEntries(): WaitlistEntry[] {
487 return db
488 .query<WaitlistEntry, []>(
489 "SELECT * FROM class_waitlist ORDER BY created_at DESC",
490 )
491 .all();
492}
493
494/**
495 * Delete a waitlist entry
496 */
497export function deleteWaitlistEntry(id: string): void {
498 db.query("DELETE FROM class_waitlist WHERE id = ?").run(id);
499}