From 2ddb5672f085de9b08c6e7101b17a01b4f2f7ffa Mon Sep 17 00:00:00 2001 From: Mathew Dunne Date: Wed, 30 Sep 2026 10:45:06 -0400 Subject: [PATCH 01/29] feat(control): classroom storage, name/code helpers, join rate limiter Co-Authored-By: Claude Opus 5.5 --- apps/control/migrations/012_classrooms.sql | 17 ++ apps/control/src/__tests__/classrooms.test.ts | 182 ++++++++++++++++ apps/control/src/classrooms.ts | 201 ++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 apps/control/migrations/012_classrooms.sql create mode 100644 apps/control/src/__tests__/classrooms.test.ts create mode 100644 apps/control/src/classrooms.ts diff --git a/apps/control/migrations/012_classrooms.sql b/apps/control/migrations/012_classrooms.sql new file mode 100644 index 00000000..1faf3b1c --- /dev/null +++ b/apps/control/migrations/012_classrooms.sql @@ -0,0 +1,17 @@ +-- Classroom join codes (decision 043). Guest users point at a classroom via +-- the Better Auth user.classroomId column, which Better Auth itself adds +-- (declared as an additionalField) — the user table does not exist yet when +-- these migrations run. +CREATE TABLE classrooms ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + ended_at TEXT, + cleaned_at TEXT +); + +-- Codes are unique only among live classrooms; "live" depends on now(), so +-- uniqueness is enforced at creation time rather than by a constraint. +CREATE INDEX idx_classrooms_code ON classrooms(code); diff --git a/apps/control/src/__tests__/classrooms.test.ts b/apps/control/src/__tests__/classrooms.test.ts new file mode 100644 index 00000000..0358303a --- /dev/null +++ b/apps/control/src/__tests__/classrooms.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { + createClassroom, + endClassroom, + FailedAttemptLimiter, + findLiveClassroomByCode, + generateClassroomCode, + getClassroom, + guestNameKey, + isClassroomLive, + listClassroomsNeedingCleanup, + listLiveClassrooms, + markClassroomCleaned, + normalizeDisplayName, +} from "../classrooms"; +import { withApp } from "./helpers"; + +describe("normalizeDisplayName", () => { + test("trims and collapses whitespace", () => { + expect(normalizeDisplayName(" Alex D ")).toBe("Alex D"); + }); + + test("accepts unicode letters, digits, apostrophes, hyphens and periods", () => { + expect(normalizeDisplayName("Zoë O'Neil-Smith Jr.")).toBe( + "Zoë O'Neil-Smith Jr.", + ); + expect(normalizeDisplayName("李雷")).toBe("李雷"); + expect(normalizeDisplayName("Sam 2")).toBe("Sam 2"); + }); + + test("rejects empty, too long, markup, and punctuation-only names", () => { + expect(normalizeDisplayName("")).toBeNull(); + expect(normalizeDisplayName(" ")).toBeNull(); + expect(normalizeDisplayName("a".repeat(41))).toBeNull(); + expect(normalizeDisplayName("