From 976816c09f310dfea8ca2496e3357d7b097f47ce Mon Sep 17 00:00:00 2001 From: Albert Dong Date: Wed, 29 Jul 2026 02:05:39 -0700 Subject: [PATCH] Migrate database times to standard timestamp formats Times were spread across four ad-hoc string encodings that every reader had to re-parse: section.time as "9:00-9:50" or "1:00pm-1:50pm", section.day as "Monday", availability.week as TEXT, and feedback.submitted_at written with datetime('now') -- which is not ISO-8601 and which JS Date parses as local time, so the admin panel patched the string by hand before reading it. Standardize on three formats, defined once in lib/time.ts: instant TEXT, ISO-8601 UTC with ms '2026-07-29T04:12:33.123Z' time of day TEXT, 24h 'HH:MM' LA wall time day of week INTEGER, ISO weekday, 1 = Monday section keeps a recurring pattern (day_of_week + start_time/end_time) since it repeats weekly. availability now stores real instants, computed on write from QUARTER_START + week + the section's weekday, so queries filter on start_at instead of rebuilding dates from three columns. Messy Airtable times are normalized once at ingest in init-sections; nothing downstream re-parses. Also add observability. Every app table gets created_at/updated_at, with updated_at maintained by AFTER UPDATE triggers so plain UPDATEs stay recorded without each call site remembering. availability gets status_changed_at, stamped by trigger on any open/hidden/taken flip including bulk resets. Observation sign-up time was previously not recorded anywhere. These columns are nullable: rows predating the migration have no known creation time and carry NULL rather than claiming they were created today. For actions that delete the row they describe -- cancellations, admin removals, withdraws -- a column cannot survive, so those go to a new append-only event_log with flat indexed columns and a JSON details blob. Actions are domain.verb so a prefix match gets a whole domain. actor_email is denormalized because process-withdraws deletes users. BetterAuth's tables are left alone; it already writes ISO-8601 UTC. Verified by applying the full migration chain against SQLite with legacy-shaped data covering each old time format: backfill produces correct 24h times (including the noon am/pm edge), unparseable rows keep their identity with NULL times rather than being dropped, and the triggers fire as intended. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 47 +++ .../admin/components/AvailabilityAudit.tsx | 32 +- .../app/admin/components/ObservationAudit.tsx | 77 ++-- .../app/api/admin/audit/availability/route.ts | 16 +- .../app/api/admin/audit/signups/route.ts | 29 +- .../admin/audit/unpaired-feedbacks/route.ts | 25 +- .../app/api/admin/availability/reset/route.ts | 13 + .../app/api/admin/feedback/[id]/pair/route.ts | 11 + laprogram/app/api/admin/signup/[id]/route.ts | 41 ++- laprogram/app/api/availability/route.ts | 110 ++++-- laprogram/app/api/cron/init-las/route.ts | 32 +- .../cron/init-section-assignments/route.ts | 71 +++- laprogram/app/api/cron/init-sections/route.ts | 78 ++-- .../app/api/cron/process-withdraws/route.ts | 15 + laprogram/app/api/feedback/route.ts | 32 +- laprogram/app/api/observation/[id]/route.ts | 44 ++- laprogram/app/api/observation/open/route.ts | 39 +- laprogram/app/api/observation/route.ts | 83 +++-- laprogram/app/api/sections/route.ts | 10 +- .../components/sections/ObservationPicker.tsx | 6 +- .../observations/schedule/ScheduleCard.tsx | 88 +++-- laprogram/app/observations/signup/SignUp.tsx | 39 +- .../signup/components/ObservationRow.tsx | 4 +- .../signup/components/PendingChanges.tsx | 10 +- laprogram/app/observations/signup/page.tsx | 3 +- laprogram/app/observations/signup/types.ts | 6 +- laprogram/lib/backup.ts | 6 +- laprogram/lib/constants.ts | 11 +- laprogram/lib/events.ts | 87 +++++ laprogram/lib/observation-weeks.ts | 22 +- laprogram/lib/time.ts | 142 ++++++++ laprogram/lib/utils.ts | 122 +------ .../migrations/0005_standard_timestamps.sql | 337 ++++++++++++++++++ laprogram/scripts/test-feedback.sql | 12 +- laprogram/scripts/testing.sql | 44 +-- laprogram/types/db.ts | 32 +- 36 files changed, 1309 insertions(+), 467 deletions(-) create mode 100644 laprogram/lib/events.ts create mode 100644 laprogram/lib/time.ts create mode 100644 laprogram/migrations/0005_standard_timestamps.sql diff --git a/CLAUDE.md b/CLAUDE.md index 9ffa7cc..06c8ced 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,9 +159,56 @@ All tables live in a single D1 database (`data`). The init migration (`migration - **Auth tables** (managed by BetterAuth — do not edit): `user`, `session`, `account`, `verification` - **`course`** — course assignments. Composite primary key `(userId, course_name, position)`. Indexed on `course_name` for listing all LAs in a course. - **`feedback`** — feedback submissions linking a giver to a recipient (references `user.id`). Stores form data as a JSON string in the `feedback` column. Indexed on `recipientId`. +- **`event_log`** — append-only audit trail (see below). A default admin user (`pdt.laprogram@gmail.com`, role `admin`) is seeded in the init migration. Test data can be loaded from `scripts/testing.sql`. +#### Time formats + +`migrations/0005_standard_timestamps.sql` put every app-owned time value on a fixed format. Nothing should be splitting a time string apart outside `lib/time.ts`. + +| Kind | Storage | Example | Helpers | +|------|---------|---------|---------| +| Instant | TEXT, ISO-8601 UTC with ms | `2026-07-29T04:12:33.123Z` | `isoNow()`, `toISO()`, `fromISO()`, SQL `SQL_NOW` | +| Time of day | TEXT, 24h `HH:MM` (LA wall time) | `09:20` | `clockToMinutes()`, `minutesToClock()`, `clockLabel()` | +| Day of week | INTEGER, ISO weekday | `1` = Monday | `dayName()`, `dayOfWeek()` | +| Quarter week | INTEGER | `5` | `getCurrentWeek()` | + +- `section` stores a **recurring** meeting pattern: `day_of_week` + `start_time`/`end_time`. It has no instants because a section repeats weekly. +- `availability` stores **real instants** (`start_at`/`end_at`), computed on write from `QUARTER_START` + week + the section's weekday via `weekdayInstant()`. Queries filter on `start_at` directly rather than recomputing dates — use `laDayBoundary(n)` for "n calendar days out in LA" cutoffs. +- Messy times from Airtable are normalized once, at ingest, in `init-sections`. Everything downstream sees `HH:MM`. +- `QUARTER_START` stays a plain `yyyy-MM-dd` date in KV — it is a calendar day, not an instant. + +BetterAuth's tables were deliberately left alone; it already writes ISO-8601 UTC into them. + +#### Observability + +Every app table carries nullable `created_at` / `updated_at`. `updated_at` is maintained by `AFTER UPDATE` triggers, so plain `UPDATE` statements stay observable without every call site remembering. `availability` also has `status_changed_at`, stamped by trigger on any open/hidden/taken flip — including bulk resets. + +These columns are nullable because rows predating the migration have no known creation time; they carry `NULL` rather than a value that would claim they were created at migration time. + +For anything that **deletes** the row it describes (observation cancellations, admin removals, withdraws), a row timestamp cannot survive — those go to `event_log` instead, via `lib/events.ts`: + +```ts +import { EVENT, eventStmt, recordEvent } from "@/lib/events"; + +// Prefer eventStmt inside an existing db.batch() so the log cannot drift +// from the change it describes. +await db.batch([ ...changes, eventStmt(db, { action: EVENT.ObservationSignup, ... })]); + +// recordEvent runs standalone and never throws. +await recordEvent(db, { action: EVENT.AvailabilityReset, ... }); +``` + +Actions are `domain.verb`, so a prefix match gets a whole domain: + +```bash +npx wrangler d1 execute data --remote --command \ + "SELECT * FROM event_log WHERE action LIKE 'observation.%' ORDER BY occurred_at DESC LIMIT 20" +``` + +`actor_email` / `target_email` are denormalized on purpose — `process-withdraws` deletes users, and the log has no foreign keys so it outlives what it describes. + ### Migrations D1 migrations live in `migrations/`. To create a new migration: diff --git a/laprogram/app/admin/components/AvailabilityAudit.tsx b/laprogram/app/admin/components/AvailabilityAudit.tsx index 6ad3e60..d0e0b02 100644 --- a/laprogram/app/admin/components/AvailabilityAudit.tsx +++ b/laprogram/app/admin/components/AvailabilityAudit.tsx @@ -5,6 +5,7 @@ import useSWR from "swr"; import { toast } from "sonner"; import { ChevronDown, Pencil } from "lucide-react"; import { fetcher, getCurrentWeek } from "@/lib/utils"; +import { clockLabel, dayName } from "@/lib/time"; import { formatName, getNamePart } from "@/lib/name"; import { useTableSort } from "@/hooks/use-table-sort"; import { useToggleSet } from "@/hooks/use-toggle-set"; @@ -44,15 +45,21 @@ type SectionEntry = { la_email: string; course_name: string; section_name: string; - section_time: string; section_id: string; - section_day: string; - section_time_raw: string; + day_of_week: number | null; + start_time: string | null; + end_time: string | null; section_location: string; position: string; - weeks: Record; + weeks: Record; }; +/** 'Monday 9:00 AM–9:50 AM', or an em dash if the section has no time yet. */ +function sectionTimeLabel(e: SectionEntry): string { + if (!e.day_of_week || !e.start_time || !e.end_time) return "—"; + return `${dayName(e.day_of_week)} ${clockLabel(e.start_time)}–${clockLabel(e.end_time)}`; +} + export function AvailabilityAudit() { const [query, setQuery] = useState(""); const [maxWeeks, setMaxWeeks] = useState(""); @@ -121,7 +128,7 @@ export function AvailabilityAudit() { // Build section entries grouped by la_id + section_id const map = new Map(); - const weekSet = new Set(); + const weekSet = new Set(); for (const row of data) { const key = `${row.la_id}|${row.section_id}`; @@ -133,10 +140,10 @@ export function AvailabilityAudit() { la_email: row.la_email, course_name: row.course_name, section_name: row.section_name, - section_time: row.section_time, section_id: row.section_id, - section_day: row.section_day, - section_time_raw: row.section_time_raw, + day_of_week: row.day_of_week, + start_time: row.start_time, + end_time: row.end_time, section_location: row.section_location, position: row.position, weeks: {}, @@ -150,7 +157,7 @@ export function AvailabilityAudit() { } const allEntries = [...map.values()]; - const weeks = [...weekSet].sort((a, b) => Number(a) - Number(b)); + const weeks = [...weekSet].sort((a, b) => a - b); const positionOptions = [ ...new Set(allEntries.map((e) => e.position)), ].sort(); @@ -349,8 +356,9 @@ export function AvailabilityAudit() { section_id: editing.section_id, course_name: editing.course_name, section_name: editing.section_name, - day: editing.section_day, - time: editing.section_time_raw, + day_of_week: editing.day_of_week, + start_time: editing.start_time, + end_time: editing.end_time, location: editing.section_location, } satisfies Section } @@ -455,7 +463,7 @@ export function AvailabilityAudit() { {entry.course_name} {entry.section_name} ( - {entry.section_time}) + {sectionTimeLabel(entry)}) {entry.position} diff --git a/laprogram/app/admin/components/ObservationAudit.tsx b/laprogram/app/admin/components/ObservationAudit.tsx index f512b47..74e642c 100644 --- a/laprogram/app/admin/components/ObservationAudit.tsx +++ b/laprogram/app/admin/components/ObservationAudit.tsx @@ -4,11 +4,11 @@ import { Fragment, useState } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Check, ChevronRight, Clock, Eye, Trash2 } from "lucide-react"; -import { fetcher, parseSectionTime, minutesToLabel } from "@/lib/utils"; +import { fetcher } from "@/lib/utils"; import { formatName, getNamePart } from "@/lib/name"; import { useTableSort } from "@/hooks/use-table-sort"; import { useToggleSet } from "@/hooks/use-toggle-set"; -import { LA_POSITION_MAP } from "@/lib/constants"; +import { LA_POSITION_MAP, TIMEZONE } from "@/lib/constants"; import { NameSortHeader } from "./NameSortHeader"; import { SearchBar } from "./SearchBar"; import { PositionFilter } from "./PositionFilter"; @@ -43,13 +43,13 @@ function positionLabel(p: string | null) { return parts.map((x) => LA_POSITION_MAP.get(x) ?? x).join(", "); } -function formatSubmittedAt(s: string | null): string { - if (!s) return "—"; - const iso = s.includes("T") ? s : s.replace(" ", "T") + "Z"; +/** Renders a stored instant in LA time. */ +function formatInstant(iso: string | null): string { + if (!iso) return "—"; const date = new Date(iso); if (isNaN(date.getTime())) return "—"; return date.toLocaleString("en-US", { - timeZone: "America/Los_Angeles", + timeZone: TIMEZONE, month: "numeric", day: "numeric", hour: "numeric", @@ -71,20 +71,33 @@ function StatusBadge({ completed }: { completed: boolean }) { ); } -function formatTimeRange(time: string) { - try { - const [start, end] = parseSectionTime(time); - const startLabel = minutesToLabel(start); - const endLabel = minutesToLabel(end); - const startPeriod = startLabel.slice(-2); - const endPeriod = endLabel.slice(-2); - if (startPeriod === endPeriod) { - return `${startLabel.slice(0, -3)}-${endLabel}`; - } - return `${startLabel}-${endLabel}`; - } catch { - return time; +/** '9:20-9:50 AM' for a slot, collapsing the period when both halves match. */ +function formatTimeRange(startAt: string | null, endAt: string | null) { + if (!startAt || !endAt) return "—"; + const startLabel = formatClockLA(startAt); + const endLabel = formatClockLA(endAt); + if (startLabel.slice(-2) === endLabel.slice(-2)) { + return `${startLabel.slice(0, -3)}-${endLabel}`; } + return `${startLabel}-${endLabel}`; +} + +function formatClockLA(iso: string): string { + return new Date(iso).toLocaleString("en-US", { + timeZone: TIMEZONE, + hour: "numeric", + minute: "2-digit", + }); +} + +/** 'Wk 5 · Monday' for a slot. */ +function formatSlotDay(week: number, startAt: string | null): string { + if (!startAt) return `Wk ${week}`; + const day = new Date(startAt).toLocaleString("en-US", { + timeZone: TIMEZONE, + weekday: "long", + }); + return `Wk ${week} · ${day}`; } type GroupBy = "observer" | "observee"; @@ -534,15 +547,22 @@ export function ObservationAudit() { {r.course_name} {r.section_name} - Wk {r.week} · {r.day}{" "} - {formatTimeRange(r.time)} + {formatSlotDay(r.week, r.start_at)}{" "} + {formatTimeRange(r.start_at, r.end_at)} - + {r.completed - ? formatSubmittedAt(r.submitted_at) + ? formatInstant(r.submitted_at) : "—"} @@ -785,7 +805,8 @@ export function ObservationAudit() { {s.course_name} {s.section_name} - Wk {s.week} · {s.day} {formatTimeRange(s.time)} + {formatSlotDay(s.week, s.start_at)}{" "} + {formatTimeRange(s.start_at, s.end_at)} @@ -840,10 +861,10 @@ export function ObservationAudit() { {toDelete.observee_name} {" "} - in {toDelete.course_name} {toDelete.section_name} (Wk{" "} - {toDelete.week}, {toDelete.day}{" "} - {formatTimeRange(toDelete.time)})? The availability slot will - be reopened. + in {toDelete.course_name} {toDelete.section_name} ( + {formatSlotDay(toDelete.week, toDelete.start_at)}{" "} + {formatTimeRange(toDelete.start_at, toDelete.end_at)})? The + availability slot will be reopened. )} diff --git a/laprogram/app/api/admin/audit/availability/route.ts b/laprogram/app/api/admin/audit/availability/route.ts index 4c1d32a..ffbe9cf 100644 --- a/laprogram/app/api/admin/audit/availability/route.ts +++ b/laprogram/app/api/admin/audit/availability/route.ts @@ -8,13 +8,15 @@ export type AvailabilityAuditRow = { la_email: string; course_name: string; section_name: string; - section_time: string; section_id: string; - section_day: string; - section_time_raw: string; + /** ISO weekday, 1 = Monday. */ + day_of_week: number | null; + /** Wall-clock 'HH:MM' in LA. */ + start_time: string | null; + end_time: string | null; section_location: string; position: string; - week: string | null; + week: number | null; slot_count: number; }; @@ -38,9 +40,9 @@ export async function GET() { u.email AS la_email, s.course_name, s.section_name, - s.day || ' ' || s.time AS section_time, - s.day AS section_day, - s.time AS section_time_raw, + s.day_of_week AS day_of_week, + s.start_time AS start_time, + s.end_time AS end_time, s.location AS section_location, sa.section_id, c.position, diff --git a/laprogram/app/api/admin/audit/signups/route.ts b/laprogram/app/api/admin/audit/signups/route.ts index 7b81c1f..2c435aa 100644 --- a/laprogram/app/api/admin/audit/signups/route.ts +++ b/laprogram/app/api/admin/audit/signups/route.ts @@ -1,7 +1,7 @@ import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { getObsDate, getQuarterStart } from "@/lib/utils"; +import { fromISO } from "@/lib/time"; import { formatDateLA } from "@/app/observations/signup/types"; export type SignupRow = { @@ -16,9 +16,11 @@ export type SignupRow = { observee_position: string | null; course_name: string; section_name: string; - day: string; - time: string; - week: string; + week: number; + start_at: string | null; + end_at: string | null; + /** When the observer signed up. Null for sign-ups predating the migration. */ + signed_up_at: string | null; completed: boolean; submitted_at: string | null; feedback: Record | null; @@ -52,12 +54,13 @@ export async function GET() { // TODO: logic for extracting completed observations is not good. This should be refactored // once the major sign-up changes go through in a few weeks this quarter - const [signupsResult, feedbackResult, quarterStart] = await Promise.all([ + const [signupsResult, feedbackResult] = await Promise.all([ env.data .prepare( `SELECT o.id AS id, o.observer_id AS observer_id, + o.created_at AS signed_up_at, observer.name AS observer_name, observer.email AS observer_email, (SELECT GROUP_CONCAT(DISTINCT position) @@ -69,15 +72,15 @@ export async function GET() { FROM course WHERE userId = o.observee_id) AS observee_position, s.course_name AS course_name, s.section_name AS section_name, - s.day AS day, - a.time AS time, - a.week AS week + a.week AS week, + a.start_at AS start_at, + a.end_at AS end_at FROM observation o JOIN availability a ON o.availability_id = a.id JOIN section s ON a.section_id = s.id JOIN "user" observer ON o.observer_id = observer.id JOIN "user" observee ON o.observee_id = observee.id - ORDER BY a.week, s.day, a.time, observer.name COLLATE NOCASE`, + ORDER BY a.start_at, observer.name COLLATE NOCASE`, ) .all(), env.data @@ -92,7 +95,6 @@ export async function GET() { WHERE json_extract(feedback, '$.feedback_type') = 'la_observation'`, ) .all(), - getQuarterStart(env).catch(() => null), ]); type CompletedEntry = { @@ -125,9 +127,10 @@ export async function GET() { let completed = false; let submitted_at: string | null = null; let feedback: Record | null = null; - if (quarterStart) { - const obsDate = getObsDate(r.week, r.day, quarterStart); - const expected = `${r.section_name} — ${formatDateLA(obsDate)}`; + // The form stored the slot as a label ("1A — 5/12"); rebuild it from the + // slot's own instant to find the matching submission. + if (r.start_at) { + const expected = `${r.section_name} — ${formatDateLA(fromISO(r.start_at))}`; const key = `${r.observer_email.toLowerCase()}|${r.observee_id}|${expected}`; const entry = completedKeys.get(key); if (entry) { diff --git a/laprogram/app/api/admin/audit/unpaired-feedbacks/route.ts b/laprogram/app/api/admin/audit/unpaired-feedbacks/route.ts index 0555292..f3af649 100644 --- a/laprogram/app/api/admin/audit/unpaired-feedbacks/route.ts +++ b/laprogram/app/api/admin/audit/unpaired-feedbacks/route.ts @@ -1,7 +1,7 @@ import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { getObsDate, getQuarterStart } from "@/lib/utils"; +import { fromISO } from "@/lib/time"; import { formatDateLA } from "@/app/observations/signup/types"; export type UnpairedFeedback = { @@ -22,8 +22,7 @@ type SignupKeyRow = { observer_email: string; observee_id: string; section_name: string; - day: string; - week: string; + start_at: string | null; }; export async function GET() { @@ -38,15 +37,14 @@ export async function GET() { const { env } = await getCloudflareContext({ async: true }); - const [signupsResult, feedbackResult, quarterStart] = await Promise.all([ + const [signupsResult, feedbackResult] = await Promise.all([ env.data .prepare( `SELECT observer.email AS observer_email, o.observee_id AS observee_id, s.section_name AS section_name, - s.day AS day, - a.week AS week + a.start_at AS start_at FROM observation o JOIN availability a ON o.availability_id = a.id JOIN section s ON a.section_id = s.id @@ -70,18 +68,15 @@ export async function GET() { WHERE json_extract(f.feedback, '$.feedback_type') = 'la_observation'`, ) .all(), - getQuarterStart(env).catch(() => null), ]); const validKeys = new Set(); - if (quarterStart) { - for (const s of signupsResult.results) { - const obsDate = getObsDate(s.week, s.day, quarterStart); - const expected = `${s.section_name} — ${formatDateLA(obsDate)}`; - validKeys.add( - `${s.observer_email.toLowerCase()}|${s.observee_id}|${expected}`, - ); - } + for (const s of signupsResult.results) { + if (!s.start_at) continue; + const expected = `${s.section_name} — ${formatDateLA(fromISO(s.start_at))}`; + validKeys.add( + `${s.observer_email.toLowerCase()}|${s.observee_id}|${expected}`, + ); } const unpaired = feedbackResult.results.filter((f) => { diff --git a/laprogram/app/api/admin/availability/reset/route.ts b/laprogram/app/api/admin/availability/reset/route.ts index 44f8734..5bd1260 100644 --- a/laprogram/app/api/admin/availability/reset/route.ts +++ b/laprogram/app/api/admin/availability/reset/route.ts @@ -1,6 +1,7 @@ import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { EVENT, recordEvent } from "@/lib/events"; const VALID_POSITIONS = new Set([ "new", @@ -52,5 +53,17 @@ export async function POST(request: Request) { const result = await stmt.run(); + // Per-slot status_changed_at is maintained by trigger; this records who + // pulled the lever and how wide the blast radius was. + await recordEvent(env.data, { + action: EVENT.AvailabilityReset, + entityType: "availability", + actor: { id: session.user.id, email: session.user.email }, + details: { + positions: positions.length === 0 ? "all" : positions, + reset: result.meta.changes, + }, + }); + return Response.json({ reset: result.meta.changes }); } diff --git a/laprogram/app/api/admin/feedback/[id]/pair/route.ts b/laprogram/app/api/admin/feedback/[id]/pair/route.ts index c6cc370..ecf7958 100644 --- a/laprogram/app/api/admin/feedback/[id]/pair/route.ts +++ b/laprogram/app/api/admin/feedback/[id]/pair/route.ts @@ -1,6 +1,7 @@ import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { EVENT, recordEvent } from "@/lib/events"; export async function POST( request: Request, @@ -51,5 +52,15 @@ export async function POST( return new Response("Feedback not found", { status: 404 }); } + // updated_at moves by trigger; the log keeps what the edit actually was, + // since it rewrites the submitter recorded inside the feedback JSON. + await recordEvent(env.data, { + action: EVENT.FeedbackPair, + entityType: "feedback", + entityId: id, + actor: { id: session.user.id, email: session.user.email }, + details: { paired_name: body.name, paired_email: body.email }, + }); + return Response.json({ success: true }); } diff --git a/laprogram/app/api/admin/signup/[id]/route.ts b/laprogram/app/api/admin/signup/[id]/route.ts index cfe8f0f..f96956f 100644 --- a/laprogram/app/api/admin/signup/[id]/route.ts +++ b/laprogram/app/api/admin/signup/[id]/route.ts @@ -1,6 +1,7 @@ import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { EVENT, eventStmt } from "@/lib/events"; export async function DELETE( _request: Request, @@ -25,10 +26,27 @@ export async function DELETE( const observation = await db .prepare( - "SELECT availability_id, observee_id FROM observation WHERE id = ?", + `SELECT o.availability_id, o.observee_id, o.observer_id, o.created_at AS signed_up_at, + a.week, a.start_at, a.end_at, + observer.email AS observer_email, observee.email AS observee_email + FROM observation o + JOIN availability a ON o.availability_id = a.id + JOIN "user" observer ON o.observer_id = observer.id + JOIN "user" observee ON o.observee_id = observee.id + WHERE o.id = ?`, ) .bind(id) - .first<{ availability_id: string; observee_id: string }>(); + .first<{ + availability_id: string; + observee_id: string; + observer_id: string; + signed_up_at: string | null; + week: number; + start_at: string | null; + end_at: string | null; + observer_email: string; + observee_email: string; + }>(); if (!observation) { return new Response("Observation not found", { status: 404 }); @@ -44,6 +62,25 @@ export async function DELETE( "UPDATE availability SET status = 'open' WHERE la_id = ? AND status = 'hidden'", ) .bind(observation.observee_id), + eventStmt(db, { + action: EVENT.ObservationAdminRemove, + entityType: "observation", + entityId: id, + actor: { id: session.user.id, email: session.user.email }, + target: { + id: observation.observee_id, + email: observation.observee_email, + }, + details: { + availability_id: observation.availability_id, + observer_id: observation.observer_id, + observer_email: observation.observer_email, + week: observation.week, + start_at: observation.start_at, + end_at: observation.end_at, + signed_up_at: observation.signed_up_at, + }, + }), ]); return Response.json({ success: true }); diff --git a/laprogram/app/api/availability/route.ts b/laprogram/app/api/availability/route.ts index 8c5affe..f59f547 100644 --- a/laprogram/app/api/availability/route.ts +++ b/laprogram/app/api/availability/route.ts @@ -1,7 +1,9 @@ import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { headers } from "next/headers"; -import { getCurrentWeek } from "@/lib/utils"; +import { getCurrentWeek, parseQuarterStart } from "@/lib/utils"; +import { toISO, weekdayInstant } from "@/lib/time"; +import { EVENT, eventStmt } from "@/lib/events"; import { QUARTER_START_KEY } from "@/lib/constants"; import { AvailabilityRow } from "@/types/db"; @@ -13,9 +15,13 @@ interface AvailabilityPayload { interface AvailabilityWeek { week: number; - time: string; + /** Wall-clock 'HH:MM' in LA. */ + start_time: string; + end_time: string; } +const CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/; + export async function POST(request: Request) { try { const { env } = await getCloudflareContext({ async: true }); @@ -38,6 +44,12 @@ export async function POST(request: Request) { return new Response("Missing section_id or weeks", { status: 400 }); } + if ( + weeks.some((w) => !CLOCK.test(w.start_time) || !CLOCK.test(w.end_time)) + ) { + return new Response("Times must be 'HH:MM'", { status: 400 }); + } + const isAdmin = session.user.role === "admin"; const userId = la_id && isAdmin ? la_id : session.user.id; @@ -52,15 +64,29 @@ export async function POST(request: Request) { return new Response("No section assignment found", { status: 403 }); } + // The weekday a slot lands on comes from the section it belongs to. + const section = await db + .prepare("SELECT day_of_week FROM section WHERE id = ?") + .bind(section_id) + .first<{ day_of_week: number | null }>(); + + if (!section?.day_of_week) { + return new Response("Section has no scheduled day", { status: 409 }); + } + + const quarterStartRaw = (await env.config.get(QUARTER_START_KEY)) ?? ""; + if (!quarterStartRaw) { + return new Response("QUARTER_START not configured", { status: 409 }); + } + const quarterStart = parseQuarterStart(quarterStartRaw); + // filter to just the availabilities in the future (only those can be edited) - const currentWeek = getCurrentWeek( - (await env.config.get(QUARTER_START_KEY)) ?? "", - ); + const currentWeek = getCurrentWeek(quarterStartRaw); // grab future existing availability + statuses by week const existingAvailability = await db .prepare( - "SELECT id, CAST(week as INTEGER) as week, status FROM availability WHERE la_id = ? AND section_id = ? AND CAST(week AS INTEGER) >= ?", + "SELECT id, week, status FROM availability WHERE la_id = ? AND section_id = ? AND week >= ?", ) .bind(userId, section_id, currentWeek) .all<{ id: string; week: number; status: string }>(); @@ -95,22 +121,54 @@ export async function POST(request: Request) { stmts.push( db .prepare( - "INSERT INTO availability (id, la_id, section_id, time, week, status) VALUES (?, ?, ?, ?, ?, ?)", + `INSERT INTO availability + (id, la_id, section_id, week, start_at, end_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .bind( crypto.randomUUID(), userId, section_id, - w.time, - w.week.toString(), + w.week, + toISO( + weekdayInstant( + quarterStart, + w.week, + section.day_of_week, + w.start_time, + ), + ), + toISO( + weekdayInstant( + quarterStart, + w.week, + section.day_of_week, + w.end_time, + ), + ), status, ), ); } - if (stmts.length > 0) { - await db.batch(stmts); - } + stmts.push( + eventStmt(db, { + action: EVENT.AvailabilitySave, + entityType: "availability", + entityId: section_id, + actor: { id: session.user.id, email: session.user.email }, + target: userId === session.user.id ? null : { id: userId }, + details: { + section_id, + removed: deleteIds.length, + inserted: weeksToInsert.length, + weeks: weeksToInsert.map((w) => w.week), + on_behalf_of: userId === session.user.id ? null : userId, + }, + }), + ); + + await db.batch(stmts); return Response.json({ success: true, @@ -146,22 +204,18 @@ export async function GET(request: Request) { const isAdmin = session.user.role === "admin"; const userId = laIdParam && isAdmin ? laIdParam : session.user.id; - let result; - if (sectionId) { - result = await db - .prepare( - "SELECT id, section_id, time, CAST(week AS INTEGER) as week, status FROM availability WHERE la_id = ? AND section_id = ?", - ) - .bind(userId, sectionId) - .all(); - } else { - result = await db - .prepare( - "SELECT id, section_id, time, CAST(week AS INTEGER) as week, status FROM availability WHERE la_id = ?", - ) - .bind(userId) - .all(); - } + const columns = "id, section_id, week, start_at, end_at, status"; + const result = sectionId + ? await db + .prepare( + `SELECT ${columns} FROM availability WHERE la_id = ? AND section_id = ?`, + ) + .bind(userId, sectionId) + .all() + : await db + .prepare(`SELECT ${columns} FROM availability WHERE la_id = ?`) + .bind(userId) + .all(); return Response.json(result.results); } catch (error) { diff --git a/laprogram/app/api/cron/init-las/route.ts b/laprogram/app/api/cron/init-las/route.ts index 6e07aea..e35b077 100644 --- a/laprogram/app/api/cron/init-las/route.ts +++ b/laprogram/app/api/cron/init-las/route.ts @@ -7,8 +7,8 @@ import { import { backupDatabase } from "@/lib/backup"; import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; -import { TZDate } from "@date-fns/tz"; -import { TIMEZONE } from "@/lib/constants"; +import { SQL_NOW, isoNow } from "@/lib/time"; +import { EVENT, SYSTEM_ACTOR, recordEvent } from "@/lib/events"; export async function POST(request: Request) { try { @@ -102,7 +102,7 @@ export async function POST(request: Request) { : record.fields[ "Assigned Sections (click or mouseover to see all info)" ]; - const now = TZDate.tz(TIMEZONE).toISOString(); + const now = isoNow(); if (!name || !email) { errors.push(`Skipping record ${record.id}: missing name or email`); @@ -146,8 +146,11 @@ export async function POST(request: Request) { courseStmts.push( db .prepare( - `INSERT INTO course (userId, course_name, position) VALUES (?1, ?2, ?3) - ON CONFLICT (userId, course_name) DO UPDATE SET position=?3`, + `INSERT INTO course (userId, course_name, position, updated_at) + VALUES (?1, ?2, ?3, ${SQL_NOW}) + ON CONFLICT (userId, course_name) DO UPDATE SET + position = ?3, + updated_at = excluded.updated_at`, ) .bind(userId, courseName, position), ); @@ -165,8 +168,11 @@ export async function POST(request: Request) { courseStmts.push( db .prepare( - `INSERT INTO course (userId, course_name, position) VALUES ("no_user_id", ?1, "") - ON CONFLICT (userId, course_name) DO UPDATE SET position=""`, + `INSERT INTO course (userId, course_name, position, updated_at) + VALUES ("no_user_id", ?1, "", ${SQL_NOW}) + ON CONFLICT (userId, course_name) DO UPDATE SET + position = "", + updated_at = excluded.updated_at`, ) .bind(courseName), ); @@ -174,6 +180,18 @@ export async function POST(request: Request) { await db.batch([...userStmts, ...courseStmts]); + await recordEvent(db, { + action: EVENT.SyncLAs, + entityType: "course", + actor: SYSTEM_ACTOR, + details: { + records: allRecords.length, + users: userStmts.length, + courses: courseStmts.length, + errors: errors.length, + }, + }); + const summary = `Processed ${allRecords.length} records. Users: ${userStmts.length}, Courses: ${courseStmts.length}` + (errors.length > 0 ? `\nErrors:\n${errors.join("\n")}` : ""); diff --git a/laprogram/app/api/cron/init-section-assignments/route.ts b/laprogram/app/api/cron/init-section-assignments/route.ts index 77373ff..817f57e 100644 --- a/laprogram/app/api/cron/init-section-assignments/route.ts +++ b/laprogram/app/api/cron/init-section-assignments/route.ts @@ -3,7 +3,14 @@ import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; import type { AirtableRecord } from "@/lib/airtable"; import { backupDatabase } from "@/lib/backup"; -import { defaultAvailabilityTime } from "@/lib/utils"; +import { getQuarterStart } from "@/lib/utils"; +import { + defaultAvailabilityWindow, + toISO, + weekdayInstant, +} from "@/lib/time"; +import { EVENT, SYSTEM_ACTOR, recordEvent } from "@/lib/events"; +import { OBSERVATION_WEEK_RANGE } from "@/lib/constants"; export async function POST(request: Request) { try { @@ -73,6 +80,10 @@ export async function POST(request: Request) { let message = ""; let staleCount = 0; + // Default availability slots are real instants, so they need the quarter's + // start date to exist before any of them can be written. + const quarterStart = await getQuarterStart(env); + for (const record of allRecords) { const email = ( Array.isArray(record.fields.Email) @@ -115,9 +126,15 @@ export async function POST(request: Request) { for (const rawName of airtableSections) { const section = await db - .prepare("SELECT id, time FROM section WHERE raw = ?") + .prepare( + "SELECT id, day_of_week, end_time FROM section WHERE raw = ?", + ) .bind(rawName) - .first<{ id: string; time: string }>(); + .first<{ + id: string; + day_of_week: number | null; + end_time: string | null; + }>(); if (!section) { errors.push( @@ -136,19 +153,45 @@ export async function POST(request: Request) { ) .bind(user.id, section.id), ); - const availTime = defaultAvailabilityTime(section.time); - for (const week of [3, 4, 5, 6, 7, 8, 9, 10]) { + + if (!section.day_of_week || !section.end_time) { + errors.push( + `Added ${email} to ${section.id} without default availability: section has no scheduled time`, + ); + message += `adding ${email} ${section.id} (no default availability)\n`; + continue; + } + + const window = defaultAvailabilityWindow(section.end_time); + for (const week of OBSERVATION_WEEK_RANGE) { insertStmts.push( db .prepare( - `INSERT OR IGNORE INTO availability (id, la_id, section_id, time, week, status) VALUES (?, ?, ?, ?, ?, 'open')`, + `INSERT OR IGNORE INTO availability + (id, la_id, section_id, week, start_at, end_at, status) + VALUES (?, ?, ?, ?, ?, ?, 'open')`, ) .bind( crypto.randomUUID(), user.id, section.id, - availTime, - String(week), + week, + toISO( + weekdayInstant( + quarterStart, + week, + section.day_of_week, + window.start, + ), + ), + toISO( + weekdayInstant( + quarterStart, + week, + section.day_of_week, + window.end, + ), + ), ), ); } @@ -173,6 +216,18 @@ export async function POST(request: Request) { await db.batch([...insertStmts, ...deleteStmts]); } + await recordEvent(db, { + action: EVENT.SyncSectionAssignments, + entityType: "section_assignment", + actor: SYSTEM_ACTOR, + details: { + records: allRecords.length, + added: insertStmts.length, + removed_stale: staleCount, + errors: errors.length, + }, + }); + const summary = `Processed ${allRecords.length} records. Added: ${insertStmts.length}, Removed stale: ${staleCount}\n${message}` + (errors.length > 0 ? `\nErrors:\n${errors.join("\n")}` : ""); diff --git a/laprogram/app/api/cron/init-sections/route.ts b/laprogram/app/api/cron/init-sections/route.ts index 4cc4553..ee174f5 100644 --- a/laprogram/app/api/cron/init-sections/route.ts +++ b/laprogram/app/api/cron/init-sections/route.ts @@ -2,6 +2,8 @@ import { getCloudflareContext } from "@opennextjs/cloudflare"; import { backupDatabase } from "@/lib/backup"; import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; +import { SQL_NOW } from "@/lib/time"; +import { EVENT, SYSTEM_ACTOR, recordEvent } from "@/lib/events"; interface SectionRecord { fields: Record; @@ -66,20 +68,22 @@ export async function POST(request: Request) { }); } - const dayMap: Record = { - M: "Monday", - T: "Tuesday", - W: "Wednesday", - R: "Thursday", - F: "Friday", - }; + // ISO weekday, 1 = Monday. + const dayMap: Record = { M: 1, T: 2, W: 3, R: 4, F: 5 }; function to24(hour: number, period: string): number { if (period === "am") return hour === 12 ? 0 : hour; return hour === 12 ? 12 : hour + 12; } - function standardizeTime(raw: string): string { + /** + * Airtable section times arrive in whatever shape a human typed them + * ("2-2:50pm", "9 - 9:50 a"). This is the one place that mess is + * untangled: everything downstream sees 'HH:MM'. + */ + function standardizeTime( + raw: string, + ): { start: string; end: string } | null { const cleaned = raw .replace(/\*/g, "") .replace(/\s*-\s*/g, "-") @@ -87,10 +91,9 @@ export async function POST(request: Request) { const parts = cleaned.split("-").map((part) => { const m = part.trim().match(/^(\d+)(?::(\d+))?(am|pm|a|p)?$/i); - if (!m) return { text: part.trim(), hour: 0, mins: "00", period: "" }; + if (!m) return null; const [, hours, minutes, period] = m; return { - text: part.trim(), hour: parseInt(hours), mins: minutes ?? "00", period: period @@ -101,17 +104,25 @@ export async function POST(request: Request) { }; }); + if (parts.length !== 2 || parts.some((p) => p === null)) return null; + const [start, end] = parts as NonNullable<(typeof parts)[number]>[]; + // Infer missing am/pm on start from end time - if (parts.length === 2 && !parts[0].period && parts[1].period) { - const endPeriod = parts[1].period; - const start24 = to24(parts[0].hour, endPeriod); - const end24 = to24(parts[1].hour, endPeriod); + if (!start.period && end.period) { + const endPeriod = end.period; + const start24 = to24(start.hour, endPeriod); + const end24 = to24(end.hour, endPeriod); // If assuming same period makes start > end, flip to opposite - parts[0].period = + start.period = start24 <= end24 ? endPeriod : endPeriod === "am" ? "pm" : "am"; } - return parts.map((p) => `${p.hour}:${p.mins}${p.period}`).join("-"); + const clock = (p: { hour: number; mins: string; period: string }) => { + const h = p.period ? to24(p.hour, p.period) : p.hour; + return `${String(h).padStart(2, "0")}:${p.mins.padStart(2, "0")}`; + }; + + return { start: clock(start), end: clock(end) }; } const db = env.data; @@ -135,31 +146,39 @@ export async function POST(request: Request) { } const [, courseName, dayAbbr, rawTime, sectionName, location] = match; - const day = dayMap[dayAbbr] ?? dayAbbr; + const dayOfWeek = dayMap[dayAbbr] ?? null; const time = standardizeTime(rawTime.replace(/\([^)]*\)/g, "").trim()); + if (!dayOfWeek || !time) { + errors.push(`Failed to parse section time or day: ${raw}`); + continue; + } + stmts.push( db .prepare( - `INSERT INTO section (id, raw, course_name, section_name, day, time, location, ta_name, ta_email) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `INSERT INTO section (id, raw, course_name, section_name, day_of_week, start_time, end_time, location, ta_name, ta_email, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${SQL_NOW}) ON CONFLICT (id) DO UPDATE SET raw = excluded.raw, course_name = excluded.course_name, section_name = excluded.section_name, - day = excluded.day, - time = excluded.time, + day_of_week = excluded.day_of_week, + start_time = excluded.start_time, + end_time = excluded.end_time, location = excluded.location, ta_name = excluded.ta_name, - ta_email = excluded.ta_email`, + ta_email = excluded.ta_email, + updated_at = excluded.updated_at`, ) .bind( id, raw, courseName.trim(), sectionName.trim(), - day, - time, + dayOfWeek, + time.start, + time.end, location.trim(), taName, taEmail, @@ -171,6 +190,17 @@ export async function POST(request: Request) { await db.batch(stmts); } + await recordEvent(db, { + action: EVENT.SyncSections, + entityType: "section", + actor: SYSTEM_ACTOR, + details: { + records: allRecords.length, + written: stmts.length, + errors: errors.length, + }, + }); + const summary = `Processed ${allRecords.length} records. Sections: ${stmts.length}` + (errors.length > 0 ? `\nErrors:\n${errors.join("\n")}` : ""); diff --git a/laprogram/app/api/cron/process-withdraws/route.ts b/laprogram/app/api/cron/process-withdraws/route.ts index 452d875..51870d9 100644 --- a/laprogram/app/api/cron/process-withdraws/route.ts +++ b/laprogram/app/api/cron/process-withdraws/route.ts @@ -2,6 +2,7 @@ import { getCloudflareContext } from "@opennextjs/cloudflare"; import { backupDatabase } from "@/lib/backup"; import { headers } from "next/headers"; import { getAuth } from "@/lib/auth"; +import { EVENT, SYSTEM_ACTOR, eventStmt } from "@/lib/events"; interface WithdrewRecord { id: string; @@ -179,6 +180,20 @@ export async function POST(request: Request) { .bind(withdrewUser.id), db.prepare("DELETE FROM user WHERE id = ?").bind(withdrewUser.id), */ + // Logged inside the same batch: the rows that would have carried this + // timestamp are exactly the ones being deleted. + eventStmt(db, { + action: EVENT.UserWithdraw, + entityType: "user", + entityId: withdrewUser.id, + actor: SYSTEM_ACTOR, + target: { id: withdrewUser.id, email: withdrewLAEmail }, + details: { + name: withdrewLAName, + reverted_availability: revertStmts.length / 2, + affected_observers: observeeObs.results.map((o) => o.observer_email), + }, + }), ]); if (!hasCronSecret) { diff --git a/laprogram/app/api/feedback/route.ts b/laprogram/app/api/feedback/route.ts index 4751acb..c22a13a 100644 --- a/laprogram/app/api/feedback/route.ts +++ b/laprogram/app/api/feedback/route.ts @@ -5,6 +5,8 @@ import { v7 as uuidv7 } from "uuid"; import { Id } from "@/types/db"; import { headers } from "next/headers"; import { anonFeedbackSchema } from "@/app/feedback/view/columns"; +import { isoNow } from "@/lib/time"; +import { EVENT, eventStmt } from "@/lib/events"; import { sortBy } from "lodash"; export async function POST(request: Request) { @@ -29,13 +31,29 @@ export async function POST(request: Request) { .bind(feedback.la, feedback.course) ?.run(); - await env.data - ?.prepare( - `INSERT INTO feedback (id, recipientId, feedback, submitted_at) - VALUES (?1, ?2, ?3, datetime('now'))`, - ) - .bind(uuidv7(), recipient?.results[0].id, JSON.stringify(feedback)) - .run(); + const recipientId = recipient?.results[0].id; + const feedbackId = uuidv7(); + const submittedAt = isoNow(); + + await env.data.batch([ + env.data + .prepare( + `INSERT INTO feedback (id, recipientId, feedback, submitted_at, created_at) + VALUES (?1, ?2, ?3, ?4, ?4)`, + ) + .bind(feedbackId, recipientId, JSON.stringify(feedback), submittedAt), + eventStmt(env.data, { + action: EVENT.FeedbackSubmit, + entityType: "feedback", + entityId: feedbackId, + target: { id: recipientId }, + details: { + feedback_type: feedback.feedback_type, + role: feedback.role, + course: feedback.course, + }, + }), + ]); } catch { return new Response("Encountered database error.", { status: 500 }); } diff --git a/laprogram/app/api/observation/[id]/route.ts b/laprogram/app/api/observation/[id]/route.ts index 3f1695b..940b8ce 100644 --- a/laprogram/app/api/observation/[id]/route.ts +++ b/laprogram/app/api/observation/[id]/route.ts @@ -1,7 +1,8 @@ import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { headers } from "next/headers"; -import { getObsDate, getQuarterStart, daysUntil } from "@/lib/utils"; +import { laDayBoundary } from "@/lib/time"; +import { EVENT, eventStmt } from "@/lib/events"; import { OBSERVATION_CHANGE_DAYS_LIMIT } from "@/lib/constants"; export async function DELETE( @@ -25,11 +26,13 @@ export async function DELETE( const observation = await db .prepare( - `SELECT observation.id, observation.observer_id, observation.observee_id, observation.availability_id, - availability.week, section.day, availability.time + `SELECT observation.id, observation.observer_id, observation.observee_id, + observation.availability_id, observation.created_at AS signed_up_at, + availability.week, availability.start_at, availability.end_at, + observee.email AS observee_email FROM observation JOIN availability ON observation.availability_id = availability.id - JOIN section ON availability.section_id = section.id + JOIN user observee ON observation.observee_id = observee.id WHERE observation.id = ?`, ) .bind(id) @@ -38,9 +41,11 @@ export async function DELETE( observer_id: string; observee_id: string; availability_id: string; - week: string; - day: string; - time: string; + signed_up_at: string | null; + week: number; + start_at: string | null; + end_at: string | null; + observee_email: string; }>(); if (!observation) { @@ -53,11 +58,9 @@ export async function DELETE( }); } - // Block deletion if observation is too close - const quarterStart = await getQuarterStart(env); - const obsDate = getObsDate(observation.week, observation.day, quarterStart); - - if (daysUntil(obsDate) < OBSERVATION_CHANGE_DAYS_LIMIT) { + // Cancellation closes once the observation is within the change window. + const cutoff = laDayBoundary(OBSERVATION_CHANGE_DAYS_LIMIT); + if (!observation.start_at || observation.start_at < cutoff) { return new Response( `Cannot cancel observations within ${OBSERVATION_CHANGE_DAYS_LIMIT} days`, { status: 403 }, @@ -74,6 +77,23 @@ export async function DELETE( "UPDATE availability SET status = 'open' WHERE la_id = ? AND status = 'hidden'", ) .bind(observation.observee_id), + eventStmt(db, { + action: EVENT.ObservationCancel, + entityType: "observation", + entityId: id, + actor: { id: session.user.id, email: session.user.email }, + target: { + id: observation.observee_id, + email: observation.observee_email, + }, + details: { + availability_id: observation.availability_id, + week: observation.week, + start_at: observation.start_at, + end_at: observation.end_at, + signed_up_at: observation.signed_up_at, + }, + }), ]); return Response.json({ success: true }); diff --git a/laprogram/app/api/observation/open/route.ts b/laprogram/app/api/observation/open/route.ts index 8e9daa8..6a46c57 100644 --- a/laprogram/app/api/observation/open/route.ts +++ b/laprogram/app/api/observation/open/route.ts @@ -1,13 +1,8 @@ import { getCloudflareContext } from "@opennextjs/cloudflare"; import { getAuth } from "@/lib/auth"; import { headers } from "next/headers"; -import { ObservationAvailabilityRow } from "@/types/db"; -import { - getObsDate, - getQuarterStart, - daysUntil, - parseTimeRange, -} from "@/lib/utils"; +import { ObservationAvailability } from "@/types/db"; +import { laDayBoundary } from "@/lib/time"; import { getApplicableRules, getApplicableNotes, @@ -40,6 +35,9 @@ export async function GET() { const positions = observerCourses.results.map((r) => r.position); const { descriptions, filter } = getApplicableRules(positions); + // Slots become unavailable at the end of the day before they happen. + const cutoff = laDayBoundary(1); + const result = await env.data .prepare( `SELECT user.name AS la_name, @@ -47,16 +45,16 @@ export async function GET() { course.position AS la_position, section.course_name AS course_name, section.section_name AS section_name, - section.day AS day, section.location AS location, availability.id AS id, - availability.week AS week, - availability.time AS time + availability.start_at AS start_at, + availability.end_at AS end_at FROM availability JOIN user ON availability.la_id = user.id JOIN section ON availability.section_id = section.id JOIN course ON availability.la_id = course.userId AND section.course_name = course.course_name WHERE availability.status = 'open' + AND availability.start_at >= ? AND availability.la_id <> ? AND availability.id NOT IN ( SELECT availability_id FROM observation WHERE observer_id = ? @@ -66,23 +64,20 @@ export async function GET() { ) AND availability.week IN (${weeks.map(() => "?").join(", ")})`, ) - .bind(session.user.id, session.user.id, session.user.id, ...weeks) - .all(); + .bind( + cutoff, + session.user.id, + session.user.id, + session.user.id, + ...weeks, + ) + .all(); if (!result) { return new Response("Encountered database error.", { status: 500 }); } - // Filter out past slots, apply observation rules, and parse time ranges - const quarterStart = await getQuarterStart(env); - const slots = result.results - .filter((s) => daysUntil(getObsDate(s.week, s.day, quarterStart)) > 0) - .filter(filter) - .map(({ week, day, time, ...rest }) => ({ - ...rest, - ...parseTimeRange(week, day, time, quarterStart), - })); - + const slots = result.results.filter(filter); const notes = getApplicableNotes(observerCourses.results); return Response.json({ slots, filters: descriptions, notes }); diff --git a/laprogram/app/api/observation/route.ts b/laprogram/app/api/observation/route.ts index 82eba74..eaf8b7b 100644 --- a/laprogram/app/api/observation/route.ts +++ b/laprogram/app/api/observation/route.ts @@ -1,12 +1,8 @@ import { getAuth } from "@/lib/auth"; import { getCloudflareContext } from "@opennextjs/cloudflare"; import { headers } from "next/headers"; -import { - parseTimeRange, - getQuarterStart, - getObsDate, - daysUntil, -} from "@/lib/utils"; +import { laDayBoundary } from "@/lib/time"; +import { EVENT, eventStmt } from "@/lib/events"; import { OBSERVATION_FUTURE_LIMIT } from "@/lib/constants"; export async function POST(request: Request) { @@ -36,9 +32,10 @@ export async function POST(request: Request) { const slot = await db .prepare( `SELECT availability.id, availability.la_id, availability.section_id, - availability.time, availability.week, section.day + availability.week, availability.start_at, availability.end_at, + observee.email AS la_email FROM availability - JOIN section ON availability.section_id = section.id + JOIN user observee ON availability.la_id = observee.id WHERE availability.id = ? AND availability.status = 'open'`, ) .bind(availability_id) @@ -46,9 +43,10 @@ export async function POST(request: Request) { id: string; la_id: string; section_id: string; - time: string; - week: string; - day: string; + week: number; + start_at: string | null; + end_at: string | null; + la_email: string; }>(); if (!slot) { @@ -84,27 +82,25 @@ export async function POST(request: Request) { ); } - const quarterStart = await getQuarterStart(env); - if (daysUntil(getObsDate(slot.week, slot.day, quarterStart)) <= 0) { + // Sign-ups close at the end of the day before the observation. + const cutoff = laDayBoundary(1); + if (!slot.start_at || slot.start_at < cutoff) { return new Response("Cannot sign up for past observations", { status: 400, }); } - const observerObs = await db + const upcoming = await db .prepare( - `SELECT availability.week AS week, section.day AS day + `SELECT COUNT(*) AS count FROM observation JOIN availability ON observation.availability_id = availability.id - JOIN section ON availability.section_id = section.id - WHERE observation.observer_id = ?`, + WHERE observation.observer_id = ? AND availability.start_at >= ?`, ) - .bind(observerId) - .all<{ week: string; day: string }>(); - const futureCount = observerObs.results.filter( - (o) => daysUntil(getObsDate(o.week, o.day, quarterStart)) > 0, - ).length; - if (futureCount >= OBSERVATION_FUTURE_LIMIT) { + .bind(observerId, cutoff) + .first<{ count: number }>(); + + if ((upcoming?.count ?? 0) >= OBSERVATION_FUTURE_LIMIT) { return new Response( `You can only have ${OBSERVATION_FUTURE_LIMIT} upcoming observations at a time. Complete or cancel one before signing up for another.`, { status: 400 }, @@ -127,6 +123,20 @@ export async function POST(request: Request) { "UPDATE availability SET status = 'hidden' WHERE la_id = ? AND status = 'open'", ) .bind(slot.la_id), + eventStmt(db, { + action: EVENT.ObservationSignup, + entityType: "observation", + entityId: observationId, + actor: { id: observerId, email: session.user.email }, + target: { id: slot.la_id, email: slot.la_email }, + details: { + availability_id, + section_id: slot.section_id, + week: slot.week, + start_at: slot.start_at, + end_at: slot.end_at, + }, + }), ]); const openCount = await db @@ -170,43 +180,30 @@ export async function GET() { const result = await db .prepare( `SELECT observation.id AS id, + observation.created_at AS signed_up_at, user.name AS la_name, user.email AS la_email, user.image AS la_image, course.position AS la_position, section.course_name AS course_name, section.section_name AS section_name, - section.day AS day, - availability.week AS week, - availability.time AS time, section.location AS location, section.ta_name AS ta_name, - section.ta_email AS ta_email + section.ta_email AS ta_email, + availability.start_at AS start_at, + availability.end_at AS end_at FROM observation JOIN availability ON observation.availability_id = availability.id JOIN section ON availability.section_id = section.id JOIN user ON observation.observee_id = user.id JOIN course ON observation.observee_id = course.userId AND section.course_name = course.course_name - WHERE observation.observer_id = ?`, + WHERE observation.observer_id = ? + ORDER BY availability.start_at`, ) .bind(session.user.id) .all(); - const quarterStart = await getQuarterStart(env); - const observations = result.results.map((r) => { - const { week, day, time, ...rest } = r as Record; - return { - ...rest, - ...parseTimeRange( - week as string, - day as string, - time as string, - quarterStart, - ), - }; - }); - - return Response.json(observations); + return Response.json(result.results); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return new Response(`Failed to fetch observations: ${message}`, { diff --git a/laprogram/app/api/sections/route.ts b/laprogram/app/api/sections/route.ts index 4e3d074..8025107 100644 --- a/laprogram/app/api/sections/route.ts +++ b/laprogram/app/api/sections/route.ts @@ -23,12 +23,14 @@ export async function GET() { `SELECT section.id AS section_id, section.course_name, section.section_name, - section.day, - section.time, - section.location + section.day_of_week, + section.start_time, + section.end_time, + section.location FROM section_assignment JOIN section ON section_assignment.section_id = section.id - WHERE section_assignment.la_id = ?`, + WHERE section_assignment.la_id = ? + ORDER BY section.day_of_week, section.start_time`, ) .bind(session.user.id) .all
(); diff --git a/laprogram/app/feedback/components/sections/ObservationPicker.tsx b/laprogram/app/feedback/components/sections/ObservationPicker.tsx index 5e54051..f255f2d 100644 --- a/laprogram/app/feedback/components/sections/ObservationPicker.tsx +++ b/laprogram/app/feedback/components/sections/ObservationPicker.tsx @@ -35,7 +35,7 @@ export const ObservationPicker = withForm({ form.setFieldValue("la", obs.la_name); form.setFieldValue( "obs_section", - `${obs.section_name} — ${formatDateLA(obs.time_start)}`, + `${obs.section_name} — ${formatDateLA(obs.start_at)}`, ); form.setFieldValue("obs_la_position", obs.la_position); } @@ -43,7 +43,7 @@ export const ObservationPicker = withForm({ >
{observations - .sort((a, b) => a.time_start.getTime() - b.time_start.getTime()) + .sort((a, b) => a.start_at.getTime() - b.start_at.getTime()) .map((obs) => (
diff --git a/laprogram/app/observations/schedule/ScheduleCard.tsx b/laprogram/app/observations/schedule/ScheduleCard.tsx index 5754223..7d521fc 100644 --- a/laprogram/app/observations/schedule/ScheduleCard.tsx +++ b/laprogram/app/observations/schedule/ScheduleCard.tsx @@ -14,18 +14,20 @@ import { Slider } from "@/components/ui/slider"; import { CheckCircle2, Loader2, Lock, Users } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; +import { fetcher } from "@/lib/utils"; import { - defaultAvailabilityTime, - parseTime, - parseSectionTime, - minutesToLabel, - minutesToTimeStr, - fetcher, -} from "@/lib/utils"; + clockToMinutes, + dayName, + defaultAvailabilityWindow, + fromISO, + minutesLabel, + minutesToClock, +} from "@/lib/time"; import { AvailabilityRow, Section } from "@/types/db"; +import { OBSERVATION_WEEK_RANGE } from "@/lib/constants"; import useSWRImmutable from "swr/immutable"; -const WEEKS = [3, 4, 5, 6, 7, 8, 9, 10] as const; +const WEEKS = OBSERVATION_WEEK_RANGE; const STEP = 10; // minutes const MIN_RANGE = 30; // minutes @@ -33,7 +35,6 @@ type CourseSchedule = { sectionId: string; sectionStart: number; sectionEnd: number; - day: string; weekSlots: Map; timeRange: [number, number]; }; @@ -43,35 +44,50 @@ type WeekSlot = { timeRange: [number, number]; }; +/** Minutes since midnight in LA for a stored instant. */ +function slotMinutes(iso: string | null): number | null { + if (!iso) return null; + const d = fromISO(iso); + return d.getHours() * 60 + d.getMinutes(); +} + function buildSectionSchedule( section: Section, availability: AvailabilityRow[], currentWeek: number, -): CourseSchedule { - const [sectionStart, sectionEnd] = parseSectionTime(section.time); +): CourseSchedule | null { + if (!section.start_time || !section.end_time) return null; + + const sectionStart = clockToMinutes(section.start_time); + const sectionEnd = clockToMinutes(section.end_time); const sectionAvail = availability.filter( (a) => a.section_id === section.section_id, ); const weekSlots = new Map(); - const defaultTime = defaultAvailabilityTime(section.time); - let [defaultStart, defaultEnd] = parseSectionTime(defaultTime); + const fallback = defaultAvailabilityWindow(section.end_time); + let defaultStart = clockToMinutes(fallback.start); + let defaultEnd = clockToMinutes(fallback.end); const futureAvail = sectionAvail.find((a) => a.week >= currentWeek); - if (futureAvail) { - const [s, e] = futureAvail.time.split("-").map(parseTime); - defaultStart = s; - defaultEnd = e; + const futureStart = slotMinutes(futureAvail?.start_at ?? null); + const futureEnd = slotMinutes(futureAvail?.end_at ?? null); + if (futureStart !== null && futureEnd !== null) { + defaultStart = futureStart; + defaultEnd = futureEnd; } for (const week of WEEKS) { const weekAvail = sectionAvail.find((a) => a.week === week); - if (weekAvail) { - const [s, e] = weekAvail.time.split("-").map(parseTime); - weekSlots.set(week, { selected: true, timeRange: [s, e] }); + const start = slotMinutes(weekAvail?.start_at ?? null); + const end = slotMinutes(weekAvail?.end_at ?? null); + if (weekAvail && start !== null && end !== null) { + weekSlots.set(week, { selected: true, timeRange: [start, end] }); } else { weekSlots.set(week, { - selected: false, + // A slot with no instants predates the timestamp migration; it still + // counts as selected, it just falls back to the default window. + selected: !!weekAvail, timeRange: [defaultStart, defaultEnd], }); } @@ -81,7 +97,6 @@ function buildSectionSchedule( sectionId: section.section_id, sectionStart, sectionEnd, - day: section.day, weekSlots, timeRange: [defaultStart, defaultEnd], }; @@ -129,7 +144,9 @@ export function ScheduleCard({ // Build schedules once availability loads if (availability && !schedule) { - setSchedule(buildSectionSchedule(section, availability, currentWeek)); + setSchedule( + buildSectionSchedule(section, availability, currentWeek) ?? undefined, + ); } const signupCounts = availability @@ -140,11 +157,14 @@ export function ScheduleCard({ if (!schedule) return; setSaving(true); - const weeks: { week: number; time: string }[] = []; + const weeks: { week: number; start_time: string; end_time: string }[] = []; for (const [week, slot] of schedule.weekSlots) { if (slot.selected) { - const timeStr = `${minutesToTimeStr(slot.timeRange[0])}-${minutesToTimeStr(slot.timeRange[1])}`; - weeks.push({ week, time: timeStr }); + weeks.push({ + week, + start_time: minutesToClock(slot.timeRange[0]), + end_time: minutesToClock(slot.timeRange[1]), + }); } } @@ -161,7 +181,8 @@ export function ScheduleCard({ const freshAvailability = await mutateAvailability(); if (section && freshAvailability) { setSchedule( - buildSectionSchedule(section, freshAvailability, currentWeek), + buildSectionSchedule(section, freshAvailability, currentWeek) ?? + undefined, ); } setDirty(false); @@ -228,8 +249,9 @@ export function ScheduleCard({ {section.course_name} {section.section_name} - {section.day} · {minutesToLabel(schedule.sectionStart)}– - {minutesToLabel(schedule.sectionEnd)} · {section.location} + {dayName(section.day_of_week)} ·{" "} + {minutesLabel(schedule.sectionStart)}– + {minutesLabel(schedule.sectionEnd)} · {section.location}
@@ -286,8 +308,8 @@ export function ScheduleCard({ minStepsBetweenThumbs={MIN_RANGE / STEP} /> - {minutesToLabel(schedule.timeRange[0])}- - {minutesToLabel(schedule.timeRange[1])} + {minutesLabel(schedule.timeRange[0])}- + {minutesLabel(schedule.timeRange[1])}
@@ -348,8 +370,8 @@ export function ScheduleCard({ showThumbs={false} /> - {minutesToLabel(slot.timeRange[0])}- - {minutesToLabel(slot.timeRange[1])} + {minutesLabel(slot.timeRange[0])}- + {minutesLabel(slot.timeRange[1])} )} diff --git a/laprogram/app/observations/signup/SignUp.tsx b/laprogram/app/observations/signup/SignUp.tsx index 1690db4..ade4fe1 100644 --- a/laprogram/app/observations/signup/SignUp.tsx +++ b/laprogram/app/observations/signup/SignUp.tsx @@ -6,10 +6,11 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Plus, CalendarClock, User, MapPin, Filter, Info } from "lucide-react"; import { toast } from "sonner"; -import { fetcher, getObsDate, hydrateDates, nowLA } from "@/lib/utils"; +import { fetcher, hydrateDates, parseQuarterStart } from "@/lib/utils"; +import { nowLA, weekdayDate } from "@/lib/time"; import { differenceInCalendarDays, isSameDay } from "date-fns"; -import { DAY_INDEX, LA_POSITION_MAP } from "@/lib/constants"; -import type { ObservationAvailability } from "@/types/db"; +import { LA_POSITION_MAP, SECTION_WEEKDAYS } from "@/lib/constants"; +import type { ObservationAvailability, ObservationSlot } from "@/types/db"; import type { MyObservation } from "./types"; import { formatDateLA, formatTimeLA } from "./types"; import { PendingChanges } from "./components/PendingChanges"; @@ -24,16 +25,14 @@ import { } from "@/lib/constants"; import { TZDate } from "@date-fns/tz"; -type DateTab = { week: string; date: TZDate; label: string }; +type DateTab = { week: number; date: TZDate; label: string }; -function buildDateTabs( - weeks: string[], - quarterStart: TZDate | string, -): DateTab[] { +function buildDateTabs(weeks: number[], quarterStart: string): DateTab[] { + const start = parseQuarterStart(quarterStart); const tabs: DateTab[] = []; - for (const week of weeks.sort((a, b) => parseInt(a) - parseInt(b))) { - for (const day of DAY_INDEX.slice(0, 5)) { - const date = getObsDate(week, day, quarterStart); + for (const week of weeks) { + for (const dayOfWeek of SECTION_WEEKDAYS) { + const date = weekdayDate(start, week, dayOfWeek); tabs.push({ week, date, @@ -49,10 +48,10 @@ export function SignUp({ weeks, }: { quarterStart: string; - weeks: string[]; + weeks: number[]; }) { const { data: openData, mutate: mutateOpen } = useSWR<{ - slots: ObservationAvailability[]; + slots: ObservationSlot[]; filters: string[]; notes: string[]; }>( @@ -83,7 +82,7 @@ export function SignUp({ // Count slots per tab from ISO dates const slotCounts = new Map(); for (const slot of openSlots ?? []) { - const label = formatDateLA(slot.time_start); + const label = formatDateLA(slot.start_at); slotCounts.set(label, (slotCounts.get(label) ?? 0) + 1); } @@ -179,7 +178,7 @@ export function SignUp({ const available = (openSlots ?? []).filter( (s) => selectedDate && - isSameDay(s.time_start, selectedDate) && + isSameDay(s.start_at, selectedDate) && !pendingAdds.has(s.id), ); const pendingAddSlots = (openSlots ?? []).filter((s) => @@ -192,10 +191,10 @@ export function SignUp({ const futureObs: MyObservation[] = []; for (const obs of myObservations ?? []) { - if (obs.time_start < nowLA()) { + if (obs.start_at < nowLA()) { pastObs.push(obs); } else if ( - differenceInCalendarDays(obs.time_start, nowLA()) < + differenceInCalendarDays(obs.start_at, nowLA()) < OBSERVATION_CHANGE_DAYS_LIMIT ) { upcomingObs.push(obs); @@ -302,7 +301,7 @@ export function SignUp({
{available .sort( - (a, b) => a.time_start.getTime() - b.time_start.getTime(), + (a, b) => a.start_at.getTime() - b.start_at.getTime(), ) .map((slot) => (
- {formatTimeLA(slot.time_start)}– - {formatTimeLA(slot.time_end)} + {formatTimeLA(slot.start_at)}– + {formatTimeLA(slot.end_at)} diff --git a/laprogram/app/observations/signup/components/ObservationRow.tsx b/laprogram/app/observations/signup/components/ObservationRow.tsx index c0b920a..23fc115 100644 --- a/laprogram/app/observations/signup/components/ObservationRow.tsx +++ b/laprogram/app/observations/signup/components/ObservationRow.tsx @@ -41,10 +41,10 @@ export function ObservationRow({

{LA_POSITION_MAP.get(obs.la_position) ?? obs.la_position} ·{" "} {obs.course_name} {obs.section_name} ·{" "} - {formatDateLA(obs.time_start)} + {formatDateLA(obs.start_at)}

- {formatTimeLA(obs.time_start)}–{formatTimeLA(obs.time_end)} ·{" "} + {formatTimeLA(obs.start_at)}–{formatTimeLA(obs.end_at)} ·{" "} {obs.location}

{obs.ta_name && ( diff --git a/laprogram/app/observations/signup/components/PendingChanges.tsx b/laprogram/app/observations/signup/components/PendingChanges.tsx index fc19659..6241e82 100644 --- a/laprogram/app/observations/signup/components/PendingChanges.tsx +++ b/laprogram/app/observations/signup/components/PendingChanges.tsx @@ -2,7 +2,7 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Plus, X, Check } from "lucide-react"; -import type { ObservationAvailability } from "@/types/db"; +import type { ObservationSlot } from "@/types/db"; import { LA_POSITION_MAP } from "@/lib/constants"; import type { MyObservation } from "../types"; import { formatDateLA, formatTimeLA } from "../types"; @@ -12,7 +12,7 @@ function PendingRow({ strikethrough, action, }: { - slot: ObservationAvailability; + slot: ObservationSlot; strikethrough?: boolean; action: React.ReactNode; }) { @@ -26,12 +26,12 @@ function PendingRow({ <> {LA_POSITION_MAP.get(slot.la_position) ?? slot.la_position}{" "} · {slot.course_name} {slot.section_name} ·{" "} - {formatDateLA(slot.time_start)}{" "} + {formatDateLA(slot.start_at)}{" "} )}

- {formatTimeLA(slot.time_start)}–{formatTimeLA(slot.time_end)} ·{" "} + {formatTimeLA(slot.start_at)}–{formatTimeLA(slot.end_at)} ·{" "} {slot.location}

@@ -47,7 +47,7 @@ export function PendingChanges({ onUndoRemove, onConfirm, }: { - addSlots: ObservationAvailability[]; + addSlots: ObservationSlot[]; removeSlots: MyObservation[]; onUndoAdd: (id: string) => void; onUndoRemove: (id: string) => void; diff --git a/laprogram/app/observations/signup/page.tsx b/laprogram/app/observations/signup/page.tsx index 816f77e..ac29844 100644 --- a/laprogram/app/observations/signup/page.tsx +++ b/laprogram/app/observations/signup/page.tsx @@ -36,7 +36,6 @@ export default async function ObservationsPage() { } const quarterStart = (await env.config.get(QUARTER_START_KEY)) ?? ""; - const sortedWeeks = [...weeks].sort((a, b) => parseInt(a) - parseInt(b)); - return ; + return ; } diff --git a/laprogram/app/observations/signup/types.ts b/laprogram/app/observations/signup/types.ts index 86dc974..7cfd5c5 100644 --- a/laprogram/app/observations/signup/types.ts +++ b/laprogram/app/observations/signup/types.ts @@ -1,12 +1,14 @@ -import type { ObservationAvailability } from "@/types/db"; +import type { ObservationSlot } from "@/types/db"; import { TZDate, tz } from "@date-fns/tz"; import { format } from "date-fns"; import { TIMEZONE } from "@/lib/constants"; -export type MyObservation = ObservationAvailability & { +export type MyObservation = ObservationSlot & { la_image: string | null; ta_name: string | null; ta_email: string | null; + /** When the observer signed up. Null for sign-ups predating the migration. */ + signed_up_at: string | null; }; export function formatTimeLA(d: TZDate): string { diff --git a/laprogram/lib/backup.ts b/laprogram/lib/backup.ts index da2e675..2691d82 100644 --- a/laprogram/lib/backup.ts +++ b/laprogram/lib/backup.ts @@ -1,6 +1,5 @@ -import { TZDate } from "@date-fns/tz"; import { getCloudflareContext } from "@opennextjs/cloudflare"; -import { TIMEZONE } from "./constants"; +import { isoNow } from "./time"; const TABLES = [ "user", @@ -13,6 +12,7 @@ const TABLES = [ "section_assignment", "availability", "observation", + "event_log", ]; export async function backupDatabase() { @@ -25,7 +25,7 @@ export async function backupDatabase() { backup[table] = results; } - const timestamp = TZDate.tz(TIMEZONE).toISOString().replace(/[:.]/g, "-"); + const timestamp = isoNow().replace(/[:.]/g, "-"); const key = `backups/${timestamp}.json`; await env.db_backups.put(key, JSON.stringify(backup, null, 2), { diff --git a/laprogram/lib/constants.ts b/laprogram/lib/constants.ts index e4ea829..6911f72 100644 --- a/laprogram/lib/constants.ts +++ b/laprogram/lib/constants.ts @@ -18,15 +18,8 @@ export const OBSERVATION_WEEK_RANGE = [3, 4, 5, 6, 7, 8, 9, 10]; export const OBSERVATION_CHANGE_DAYS_LIMIT = 2; export const OBSERVATION_FUTURE_LIMIT = 5; -export const DAY_INDEX = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", -]; +/** Weekdays sections actually meet, as ISO weekdays (1 = Monday). */ +export const SECTION_WEEKDAYS = [1, 2, 3, 4, 5]; export const IMAGE_SIZE = 500; diff --git a/laprogram/lib/events.ts b/laprogram/lib/events.ts new file mode 100644 index 0000000..bab2f6f --- /dev/null +++ b/laprogram/lib/events.ts @@ -0,0 +1,87 @@ +import { v7 as uuidv7 } from "uuid"; + +/* +Append-only event log. Row timestamps cover "when was this last changed"; this +covers "what happened", including the things that delete the row they describe. + +Actions are `domain.verb` so a prefix match gets a whole domain: + SELECT * FROM event_log WHERE action LIKE 'observation.%' ORDER BY occurred_at DESC; +*/ + +export const EVENT = { + ObservationSignup: "observation.signup", + ObservationCancel: "observation.cancel", + ObservationAdminRemove: "observation.admin_remove", + AvailabilitySave: "availability.save", + AvailabilityReset: "availability.reset", + FeedbackSubmit: "feedback.submit", + FeedbackPair: "feedback.pair", + SyncLAs: "sync.las", + SyncSections: "sync.sections", + SyncSectionAssignments: "sync.section_assignments", + UserWithdraw: "user.withdraw", +} as const; + +export type EventAction = (typeof EVENT)[keyof typeof EVENT]; + +/** Who did it. Cron runs have no session, so pass `SYSTEM_ACTOR`. */ +export type EventActor = { + id?: string | null; + email?: string | null; +}; + +export const SYSTEM_ACTOR: EventActor = { id: null, email: "system:cron" }; + +export type EventInput = { + action: EventAction; + entityType: string; + entityId?: string | null; + actor?: EventActor | null; + /** The other party, where there is one -- the observee on a sign-up. */ + target?: EventActor | null; + /** Anything worth keeping that is not worth a column. Stored as JSON. */ + details?: Record; +}; + +/** + * Builds the insert without running it, so a log entry can ride along in the + * same `db.batch()` as the change it describes and cannot drift from it. + */ +export function eventStmt( + db: D1Database, + event: EventInput, +): D1PreparedStatement { + return db + .prepare( + `INSERT INTO event_log + (id, action, entity_type, entity_id, actor_id, actor_email, + target_id, target_email, details) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + uuidv7(), + event.action, + event.entityType, + event.entityId ?? null, + event.actor?.id ?? null, + event.actor?.email ?? null, + event.target?.id ?? null, + event.target?.email ?? null, + JSON.stringify(event.details ?? {}), + ); +} + +/** + * Logs an event on its own. Never throws: losing an audit line should not fail + * the request that produced it. Prefer `eventStmt` inside an existing batch. + */ +export async function recordEvent( + db: D1Database, + event: EventInput, +): Promise { + try { + await eventStmt(db, event).run(); + } catch (error) { + console.error(`Failed to log ${event.action}:`, error); + } +} diff --git a/laprogram/lib/observation-weeks.ts b/laprogram/lib/observation-weeks.ts index 23e2422..f6873ad 100644 --- a/laprogram/lib/observation-weeks.ts +++ b/laprogram/lib/observation-weeks.ts @@ -20,14 +20,14 @@ export function parseWeekList(raw: string | null | undefined): string[] { } /** - * Returns the weeks the given user is permitted to sign up for. A week is - * accessible if it is in the enabled-weeks list and either has no allowlist + * Returns the weeks the given user is permitted to sign up for, sorted. A week + * is accessible if it is in the enabled-weeks list and either has no allowlist * or the user's email is on it. */ export async function getAccessibleWeeks( env: CloudflareEnv, userEmail: string, -): Promise { +): Promise { const enabled = parseWeekList( await env.config.get(OBSERVATION_ENABLED_WEEKS_KEY), ); @@ -39,10 +39,14 @@ export async function getAccessibleWeeks( ); const allowlists = await env.config.get(allowlistKeys); - return enabled.filter((w) => { - const list = parseAllowlist( - allowlists.get(`${OBSERVATION_WEEK_ALLOWLIST_PREFIX}${w}`), - ); - return list.length === 0 || list.includes(email); - }); + return enabled + .filter((w) => { + const list = parseAllowlist( + allowlists.get(`${OBSERVATION_WEEK_ALLOWLIST_PREFIX}${w}`), + ); + return list.length === 0 || list.includes(email); + }) + .map(Number) + .filter((w) => Number.isInteger(w)) + .sort((a, b) => a - b); } diff --git a/laprogram/lib/time.ts b/laprogram/lib/time.ts new file mode 100644 index 0000000..21b8481 --- /dev/null +++ b/laprogram/lib/time.ts @@ -0,0 +1,142 @@ +import { TZDate } from "@date-fns/tz"; +import { addDays, addWeeks, startOfDay } from "date-fns"; +import { TIMEZONE } from "@/lib/constants"; + +/* +Canonical time formats. See migrations/0005_standard_timestamps.sql. + + instant ISO-8601 UTC with ms, '2026-07-29T04:12:33.123Z' + time of day 'HH:MM', 24-hour, zero-padded, America/Los_Angeles wall time + day of week ISO-8601 weekday, 1 = Monday ... 7 = Sunday + +Nothing outside this file should be splitting a time string apart. +*/ + +/** SQL expression producing an instant. The exact mirror of `isoNow()`. */ +export const SQL_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"; + +/** Current time in LA. Wall-clock reads (`getHours`) use the LA offset. */ +export function nowLA(): TZDate { + return TZDate.tz(TIMEZONE); +} + +/** Current instant. */ +export function isoNow(): string { + return new Date().toISOString(); +} + +/** An instant, from anything date-like. */ +export function toISO(value: Date | TZDate | number): string { + return new Date(value as Date).toISOString(); +} + +/** An instant, back into a date positioned in LA. */ +export function fromISO(iso: string): TZDate { + return new TZDate(iso, TIMEZONE); +} + +export const DAY_NAMES = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +] as const; + +/** ISO weekday (1-7) to its name. */ +export function dayName(dayOfWeek: number | null | undefined): string { + if (!dayOfWeek || dayOfWeek < 1 || dayOfWeek > 7) return ""; + return DAY_NAMES[dayOfWeek - 1]; +} + +/** A day name to its ISO weekday, or null if it is not one. */ +export function dayOfWeek(name: string): number | null { + const i = DAY_NAMES.indexOf(name as (typeof DAY_NAMES)[number]); + return i === -1 ? null : i + 1; +} + +/** 'HH:MM' to minutes since midnight. */ +export function clockToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + +/** Minutes since midnight to 'HH:MM'. */ +export function minutesToClock(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +/** 'HH:MM' to a display label, '09:20' -> '9:20 AM'. */ +export function clockLabel(hhmm: string): string { + const [h, m] = hhmm.split(":").map(Number); + const period = h >= 12 ? "PM" : "AM"; + const hour = h % 12 === 0 ? 12 : h % 12; + return `${hour}:${String(m).padStart(2, "0")} ${period}`; +} + +/** Minutes since midnight to a display label, 560 -> '9:20 AM'. */ +export function minutesLabel(minutes: number): string { + return clockLabel(minutesToClock(minutes)); +} + +/** Minutes an LA is observable by default: the tail end of their section. */ +export const DEFAULT_AVAILABILITY_MINUTES = 30; + +/** + * The default observation window for a section: its last 30 minutes. + * '09:00'-'09:50' gives '09:20'-'09:50'. + */ +export function defaultAvailabilityWindow(endTime: string): { + start: string; + end: string; +} { + const end = clockToMinutes(endTime); + return { + start: minutesToClock(Math.max(0, end - DEFAULT_AVAILABILITY_MINUTES)), + end: endTime, + }; +} + +/** + * The instant a wall-clock time falls on, for a given quarter week and weekday. + * Week 1 is the quarter's first week; dayOfWeek is ISO (1 = Monday). + */ +export function weekdayInstant( + quarterStart: TZDate, + week: number, + dayOfWeek: number, + hhmm: string, +): TZDate { + const date = weekdayDate(quarterStart, week, dayOfWeek); + const [h, m] = hhmm.split(":").map(Number); + return new TZDate( + date.getFullYear(), + date.getMonth(), + date.getDate(), + h, + m, + TIMEZONE, + ); +} + +/** Start of day for a given quarter week and ISO weekday. */ +export function weekdayDate( + quarterStart: TZDate, + week: number, + dayOfWeek: number, +): TZDate { + return startOfDay(addDays(addWeeks(quarterStart, week - 1), dayOfWeek - 1)); +} + +/** + * The instant midnight-in-LA falls on, `days` calendar days from today. + * `laDayBoundary(1)` is the start of tomorrow: the cutoff separating slots that + * are still open for sign-up from ones that have effectively passed. + */ +export function laDayBoundary(days: number): string { + return toISO(startOfDay(addDays(nowLA(), days))); +} diff --git a/laprogram/lib/utils.ts b/laprogram/lib/utils.ts index a6ddbfd..894b104 100644 --- a/laprogram/lib/utils.ts +++ b/laprogram/lib/utils.ts @@ -1,14 +1,9 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -import { DAY_INDEX, QUARTER_START_KEY, TIMEZONE } from "@/lib/constants"; +import { QUARTER_START_KEY, TIMEZONE } from "@/lib/constants"; +import { fromISO, nowLA } from "@/lib/time"; import { TZDate } from "@date-fns/tz"; -import { - parse, - addDays, - addWeeks, - startOfDay, - differenceInCalendarDays, -} from "date-fns"; +import { parse, startOfDay, differenceInCalendarDays } from "date-fns"; import { luhn } from "cdigit"; export function cn(...inputs: ClassValue[]) { @@ -20,12 +15,11 @@ export function fetcher(url: string): Promise { return fetch(url).then((r) => r.json()); } -/** Current time in LA timezone. */ -export function nowLA(): TZDate { - return TZDate.tz(TIMEZONE); -} - -function parseQuarterStart(raw: string): TZDate { +/** + * The quarter's first Monday. Stored in KV as 'yyyy-MM-dd' -- a date, not an + * instant, so it stays a plain calendar day rather than an ISO timestamp. + */ +export function parseQuarterStart(raw: string): TZDate { return startOfDay(parse(raw, "yyyy-MM-dd", nowLA())); } @@ -35,20 +29,6 @@ export async function getQuarterStart(env: CloudflareEnv): Promise { return parseQuarterStart(raw); } -export function getObsDate( - week: string | number, - day: string, - quarterStart: TZDate | string, -): TZDate { - const qs = - typeof quarterStart === "string" - ? parseQuarterStart(quarterStart) - : quarterStart; - const weekNum = typeof week === "string" ? parseInt(week, 10) : week; - const dayOffset = Math.max(DAY_INDEX.indexOf(day), 0); - return startOfDay(addDays(addWeeks(qs, weekNum - 1), dayOffset)); -} - export function daysUntil(target: TZDate): number { return differenceInCalendarDays(target, nowLA()); } @@ -61,88 +41,20 @@ export function getCurrentWeek(quarterStart: string | undefined): number { return Math.max(1, Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1); } -/** Parse a time string like "9:00" or "9:00am" into total minutes since midnight. */ -export function parseTime(timeStr: string): number { - const match = timeStr.match(/^(\d+):(\d+)(am|pm)?$/i); - if (!match) return 0; - let h = parseInt(match[1]); - const m = parseInt(match[2]); - const period = match[3]?.toLowerCase(); - if (period === "pm" && h !== 12) h += 12; - if (period === "am" && h === 12) h = 0; - return h * 60 + m; -} - -/** Parse a time range like "9:00-9:50" into [startMinutes, endMinutes]. */ -export function parseSectionTime(time: string): [number, number] { - const [start, end] = time.split("-"); - return [parseTime(start), parseTime(end)]; -} - -/** Convert minutes since midnight to display label like "9:00 AM". */ -export function minutesToLabel(minutes: number): string { - const h = Math.floor(minutes / 60); - const m = minutes % 60; - const period = h >= 12 ? "PM" : "AM"; - const displayH = h > 12 ? h - 12 : h === 0 ? 12 : h; - return `${displayH}:${m.toString().padStart(2, "0")} ${period}`; -} - -/** Convert minutes since midnight to "H:mm" string. */ -export function minutesToTimeStr(minutes: number): string { - const h = Math.floor(minutes / 60); - const m = minutes % 60; - return `${h}:${m.toString().padStart(2, "0")}`; -} - /** - * Given a section time like "9:00-9:50", return the default availability - * window: the last 30 minutes of the section, e.g. "9:20-9:50". + * JSON carries instants as strings; turn them back into dates positioned in LA + * so `format` and comparisons behave. */ -export function defaultAvailabilityTime(sectionTime: string): string { - const [, endMin] = parseSectionTime(sectionTime); - const startMin = endMin - 30; - return `${minutesToTimeStr(startMin)}-${minutesToTimeStr(endMin)}`; -} - -/** Build full Date objects from week/day/quarterStart + "H:mm-H:mm" time range. */ -export function parseTimeRange( - week: string | number, - day: string, - time: string, - quarterStart: TZDate, -): { time_start: TZDate; time_end: TZDate } { - const baseDate = getObsDate(week, day, quarterStart); - const [startRaw, endRaw] = time.split("-"); - const [sh, sm] = startRaw.split(":").map(Number); - const [eh, em] = endRaw.split(":").map(Number); - return { - time_start: new TZDate( - baseDate.getFullYear(), - baseDate.getMonth(), - baseDate.getDate(), - sh, - sm, - TIMEZONE, - ), - time_end: new TZDate( - baseDate.getFullYear(), - baseDate.getMonth(), - baseDate.getDate(), - eh, - em, - TIMEZONE, - ), - }; -} - export function hydrateDates< - T extends { time_start: TZDate; time_end: TZDate }, ->(items: T[]): T[] { + T extends { start_at: string | TZDate; end_at: string | TZDate }, +>(items: T[]): (Omit & { + start_at: TZDate; + end_at: TZDate; +})[] { return items.map((item) => ({ ...item, - time_start: new TZDate(item.time_start, TIMEZONE), - time_end: new TZDate(item.time_end, TIMEZONE), + start_at: fromISO(item.start_at as string), + end_at: fromISO(item.end_at as string), })); } diff --git a/laprogram/migrations/0005_standard_timestamps.sql b/laprogram/migrations/0005_standard_timestamps.sql new file mode 100644 index 0000000..9238b6b --- /dev/null +++ b/laprogram/migrations/0005_standard_timestamps.sql @@ -0,0 +1,337 @@ +-- Migration number: 0005 2026-07-29 + +/* +Move every app-owned time value onto a standard format, and record when rows +are created, changed, and acted on. + +CANONICAL FORMATS + instant TEXT, ISO-8601 UTC with milliseconds: '2026-07-29T04:12:33.123Z' + SQL: strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + TS: new Date().toISOString() + time of day TEXT, 24-hour zero-padded 'HH:MM' (America/Los_Angeles wall time) + day of week INTEGER, ISO-8601 weekday: 1 = Monday ... 7 = Sunday + week INTEGER (was TEXT) + +The BetterAuth tables (user, session, account, verification) are deliberately +untouched. BetterAuth already writes ISO-8601 UTC strings into them, so the data +is already canonical; only the declared column types are odd, and that is inert +in SQLite. Rebuilding them would mean dropping the production user table for a +cosmetic change. + +created_at / updated_at are nullable on purpose. Rows that predate this +migration genuinely have no known creation time, so they carry NULL rather than +a migration-time value that would claim they were created today. Every row +inserted from here on picks up the DEFAULT. +*/ + +/* ---------------------------------------------------------------- section -- + Replaces day ('Monday') + time ('9:00-9:50', sometimes '1:00pm-1:50pm') + with day_of_week + start_time/end_time. + + The backfill parses the two legacy time formats that reach this column: + 24-hour ('14:00') and 12-hour with a period suffix ('2:00pm'). Rows whose + time does not parse land as NULL; init-sections rewrites the whole table + from Airtable each quarter, which repairs them. +*/ + +CREATE TABLE "section_new" ( + "id" text NOT NULL PRIMARY KEY, + "raw" text NOT NULL DEFAULT '', + "course_name" text NOT NULL, + "section_name" text NOT NULL, + "day_of_week" integer CHECK ("day_of_week" BETWEEN 1 AND 7), + "start_time" text, + "end_time" text, + "location" text NOT NULL, + "ta_name" text, + "ta_email" text, + "created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + "updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +INSERT INTO "section_new" ( + id, raw, course_name, section_name, day_of_week, start_time, end_time, + location, ta_name, ta_email, created_at, updated_at +) +WITH halves AS ( + SELECT id, raw, course_name, section_name, location, ta_name, ta_email, day, + CASE WHEN instr(time, '-') > 0 + THEN substr(time, 1, instr(time, '-') - 1) END AS s_raw, + CASE WHEN instr(time, '-') > 0 + THEN substr(time, instr(time, '-') + 1) END AS e_raw + FROM "section" +), +stripped AS ( + SELECT *, + CASE WHEN s_raw LIKE '%am' OR s_raw LIKE '%pm' + THEN substr(s_raw, 1, length(s_raw) - 2) ELSE s_raw END AS s_core, + CASE WHEN s_raw LIKE '%pm' THEN 'pm' + WHEN s_raw LIKE '%am' THEN 'am' ELSE '' END AS s_per, + CASE WHEN e_raw LIKE '%am' OR e_raw LIKE '%pm' + THEN substr(e_raw, 1, length(e_raw) - 2) ELSE e_raw END AS e_core, + CASE WHEN e_raw LIKE '%pm' THEN 'pm' + WHEN e_raw LIKE '%am' THEN 'am' ELSE '' END AS e_per + FROM halves +), +parts AS ( + SELECT *, + -- A row is parseable only if both halves carry an 'H:MM'. Rows that + -- are not keep their identity and take NULL times; dropping them + -- would orphan section_assignment. + (instr(s_core, ':') > 0 AND instr(e_core, ':') > 0) AS parseable, + CAST(substr(s_core, 1, instr(s_core, ':') - 1) AS INTEGER) AS s_h, + substr(s_core, instr(s_core, ':') + 1) AS s_m, + CAST(substr(e_core, 1, instr(e_core, ':') - 1) AS INTEGER) AS e_h, + substr(e_core, instr(e_core, ':') + 1) AS e_m + FROM stripped +) +SELECT id, raw, course_name, section_name, + CASE day + WHEN 'Monday' THEN 1 WHEN 'Tuesday' THEN 2 WHEN 'Wednesday' THEN 3 + WHEN 'Thursday' THEN 4 WHEN 'Friday' THEN 5 WHEN 'Saturday' THEN 6 + WHEN 'Sunday' THEN 7 END, + CASE WHEN parseable THEN printf('%02d:%s', + CASE WHEN s_per = 'pm' AND s_h <> 12 THEN s_h + 12 + WHEN s_per = 'am' AND s_h = 12 THEN 0 ELSE s_h END, s_m) END, + CASE WHEN parseable THEN printf('%02d:%s', + CASE WHEN e_per = 'pm' AND e_h <> 12 THEN e_h + 12 + WHEN e_per = 'am' AND e_h = 12 THEN 0 ELSE e_h END, e_m) END, + location, ta_name, ta_email, NULL, NULL +FROM parts; + +DROP TABLE "section"; +ALTER TABLE "section_new" RENAME TO "section"; + +CREATE INDEX "section_course" ON "section" ("course_name"); +CREATE INDEX "section_raw" ON "section" ("raw"); + +/* ----------------------------------------------------------------- course */ + +CREATE TABLE "course_new" ( + "userId" text NOT NULL, + "course_name" text NOT NULL, + "position" text NOT NULL, + "created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + "updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY ("userId", "course_name"), + FOREIGN KEY ("userId") REFERENCES "user" ("id") +); + +INSERT INTO "course_new" (userId, course_name, position, created_at, updated_at) +SELECT userId, course_name, position, NULL, NULL FROM "course"; + +DROP TABLE "course"; +ALTER TABLE "course_new" RENAME TO "course"; + +CREATE INDEX "course_name" ON "course" ("course_name"); + +/* ----------------------------------------------------- section_assignment */ + +CREATE TABLE "section_assignment_new" ( + "la_id" text NOT NULL, + "section_id" text NOT NULL, + "created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + "updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY ("la_id", "section_id"), + FOREIGN KEY ("la_id") REFERENCES "user" ("id"), + FOREIGN KEY ("section_id") REFERENCES "section" ("id") ON UPDATE CASCADE +); + +INSERT INTO "section_assignment_new" (la_id, section_id, created_at, updated_at) +SELECT la_id, section_id, NULL, NULL FROM "section_assignment"; + +DROP TABLE "section_assignment"; +ALTER TABLE "section_assignment_new" RENAME TO "section_assignment"; + +/* ----------------------------------------------------------- availability -- + week becomes INTEGER; the '9:20-9:50' string is replaced by real instants. + + start_at/end_at are left NULL for existing rows: deriving them needs + QUARTER_START, which lives in KV and is not reachable from SQL. The API + computes them on write, so rows created from here on are complete. +*/ + +CREATE TABLE "availability_new" ( + "id" text NOT NULL PRIMARY KEY, + "la_id" text NOT NULL, + "section_id" text NOT NULL, + "week" integer NOT NULL, + "start_at" text, + "end_at" text, + "status" text NOT NULL CHECK ("status" IN ('open', 'hidden', 'taken')), + "status_changed_at" text, + "created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + "updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + FOREIGN KEY ("la_id", "section_id") + REFERENCES "section_assignment" ("la_id", "section_id") + ON UPDATE CASCADE ON DELETE CASCADE +); + +INSERT INTO "availability_new" ( + id, la_id, section_id, week, start_at, end_at, status, + status_changed_at, created_at, updated_at +) +SELECT id, la_id, section_id, CAST(week AS INTEGER), NULL, NULL, status, + NULL, NULL, NULL +FROM "availability"; + +DROP TABLE "availability"; +ALTER TABLE "availability_new" RENAME TO "availability"; + +CREATE INDEX "availability_start" ON "availability" ("start_at"); +CREATE INDEX "availability_status" ON "availability" ("status"); + +/* ------------------------------------------------------------ observation -- + Sign-up time was never recorded anywhere; created_at fixes that. +*/ + +CREATE TABLE "observation_new" ( + "id" text NOT NULL PRIMARY KEY, + "observer_id" text NOT NULL, + "observee_id" text NOT NULL, + "availability_id" text NOT NULL, + "created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + "updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + FOREIGN KEY ("observer_id") REFERENCES "user" ("id"), + FOREIGN KEY ("observee_id") REFERENCES "user" ("id"), + FOREIGN KEY ("availability_id") REFERENCES "availability" ("id") ON DELETE CASCADE +); + +INSERT INTO "observation_new" ( + id, observer_id, observee_id, availability_id, created_at, updated_at +) +SELECT id, observer_id, observee_id, availability_id, NULL, NULL +FROM "observation"; + +DROP TABLE "observation"; +ALTER TABLE "observation_new" RENAME TO "observation"; + +CREATE INDEX "observation_observer" ON "observation" ("observer_id"); +CREATE INDEX "observation_availability" ON "observation" ("availability_id"); + +/* --------------------------------------------------------------- feedback -- + submitted_at was written with datetime('now'), producing + '2026-04-28 17:03:11' -- not ISO-8601, and parsed as *local* time by + JS Date, which is why the admin panel had to hand-patch the string. +*/ + +CREATE TABLE "feedback_new" ( + "id" text NOT NULL PRIMARY KEY, + "recipientId" text NOT NULL, + "feedback" text NOT NULL, + "submitted_at" text, + "created_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + "updated_at" text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + FOREIGN KEY ("recipientId") REFERENCES "user" ("id") +); + +INSERT INTO "feedback_new" ( + id, recipientId, feedback, submitted_at, created_at, updated_at +) +SELECT id, recipientId, feedback, + CASE + WHEN submitted_at IS NULL THEN NULL + WHEN submitted_at LIKE '%T%' THEN submitted_at + ELSE replace(submitted_at, ' ', 'T') || '.000Z' + END, + CASE + WHEN submitted_at IS NULL THEN NULL + WHEN submitted_at LIKE '%T%' THEN submitted_at + ELSE replace(submitted_at, ' ', 'T') || '.000Z' + END, + NULL +FROM "feedback"; + +DROP TABLE "feedback"; +ALTER TABLE "feedback_new" RENAME TO "feedback"; + +CREATE INDEX "feedback_recipient" ON "feedback" ("recipientId"); +CREATE INDEX "feedback_submitted" ON "feedback" ("submitted_at"); + +/* -------------------------------------------------------------- event_log -- + Append-only record of things that happen, especially the ones that delete + rows (cancellations, admin removals, withdraws) where a column on the row + cannot survive to tell the story. + + Flat, indexed columns carry everything you would filter or sort on, so the + common queries never need to open `details`. actor_email and target_email + are denormalized on purpose: process-withdraws removes users, and a log + entry that degrades to a dangling id is not worth much. No foreign keys -- + the log outlives what it describes. +*/ + +CREATE TABLE "event_log" ( + "id" text NOT NULL PRIMARY KEY, + "occurred_at" text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + "action" text NOT NULL, + "entity_type" text NOT NULL, + "entity_id" text, + "actor_id" text, + "actor_email" text, + "target_id" text, + "target_email" text, + "details" text NOT NULL DEFAULT '{}' +); + +CREATE INDEX "event_log_occurred" ON "event_log" ("occurred_at"); +CREATE INDEX "event_log_action" ON "event_log" ("action", "occurred_at"); +CREATE INDEX "event_log_entity" ON "event_log" ("entity_type", "entity_id"); +CREATE INDEX "event_log_actor" ON "event_log" ("actor_id", "occurred_at"); + +/* --------------------------------------------------------------- triggers -- + updated_at maintains itself, so plain UPDATEs anywhere in the codebase stay + observable without every call site remembering to set it. The + `NEW.updated_at IS OLD.updated_at` guard leaves explicit writes alone and + stops the trigger's own UPDATE from looping. +*/ + +CREATE TRIGGER "course_touch" AFTER UPDATE ON "course" FOR EACH ROW +WHEN NEW.updated_at IS OLD.updated_at +BEGIN + UPDATE "course" SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE userId = NEW.userId AND course_name = NEW.course_name; +END; + +CREATE TRIGGER "section_touch" AFTER UPDATE ON "section" FOR EACH ROW +WHEN NEW.updated_at IS OLD.updated_at +BEGIN + UPDATE "section" SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER "section_assignment_touch" AFTER UPDATE ON "section_assignment" FOR EACH ROW +WHEN NEW.updated_at IS OLD.updated_at +BEGIN + UPDATE "section_assignment" SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE la_id = NEW.la_id AND section_id = NEW.section_id; +END; + +CREATE TRIGGER "availability_touch" AFTER UPDATE ON "availability" FOR EACH ROW +WHEN NEW.updated_at IS OLD.updated_at +BEGIN + UPDATE "availability" SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = NEW.id; +END; + +-- Every open/hidden/taken flip is timestamped, including the bulk resets that +-- do not go through a per-row code path. +CREATE TRIGGER "availability_status_touch" AFTER UPDATE OF "status" ON "availability" FOR EACH ROW +WHEN NEW.status <> OLD.status +BEGIN + UPDATE "availability" SET status_changed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER "observation_touch" AFTER UPDATE ON "observation" FOR EACH ROW +WHEN NEW.updated_at IS OLD.updated_at +BEGIN + UPDATE "observation" SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = NEW.id; +END; + +CREATE TRIGGER "feedback_touch" AFTER UPDATE ON "feedback" FOR EACH ROW +WHEN NEW.updated_at IS OLD.updated_at +BEGIN + UPDATE "feedback" SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = NEW.id; +END; diff --git a/laprogram/scripts/test-feedback.sql b/laprogram/scripts/test-feedback.sql index f6b98da..ac9a445 100644 --- a/laprogram/scripts/test-feedback.sql +++ b/laprogram/scripts/test-feedback.sql @@ -6,12 +6,12 @@ INSERT OR IGNORE INTO user (id, name, email, emailVerified, createdAt, updatedAt) VALUES - ('play_user', 'Play PedLcc', 'play@test.com', 1, datetime('now'), datetime('now')), - ('fb_new_user', 'Play New', 'play+new@test.com', 1, datetime('now'), datetime('now')), - ('fb_ret_user', 'Play Ret', 'play+ret@test.com', 1, datetime('now'), datetime('now')), - ('fb_ped_user', 'Play Ped', 'play+ped@test.com', 1, datetime('now'), datetime('now')), - ('fb_lcc_user', 'Play Lcc', 'play+lcc@test.com', 1, datetime('now'), datetime('now')), - ('fb_rlcc_user', 'Play RetLcc', 'play+ret_lcc@test.com', 1, datetime('now'), datetime('now')); + ('play_user', 'Play PedLcc', 'play@test.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('fb_new_user', 'Play New', 'play+new@test.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('fb_ret_user', 'Play Ret', 'play+ret@test.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('fb_ped_user', 'Play Ped', 'play+ped@test.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('fb_lcc_user', 'Play Lcc', 'play+lcc@test.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('fb_rlcc_user', 'Play RetLcc', 'play+ret_lcc@test.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')); -- Course positions INSERT OR IGNORE INTO course (userId, course_name, position) VALUES diff --git a/laprogram/scripts/testing.sql b/laprogram/scripts/testing.sql index 7a82b5b..be3f533 100644 --- a/laprogram/scripts/testing.sql +++ b/laprogram/scripts/testing.sql @@ -3,15 +3,15 @@ -- Users INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt) VALUES - ('user_1', 'Alice Kim', 'pdt.laprogram+1@gmail.com', 0, datetime('now'), datetime('now')), - ('user_2', 'Bob Chen', 'pdt.laprogram+2@gmail.com', 1, datetime('now'), datetime('now')), - ('user_3', 'Carol Davis', 'pdt.laprogram+3@gmail.com', 1, datetime('now'), datetime('now')), - ('user_4', 'Dan Nguyen', 'pdt.laprogram+4@gmail.com', 1, datetime('now'), datetime('now')), - ('user_5', 'Eve Park', 'pdt.laprogram+5@gmail.com', 1, datetime('now'), datetime('now')), - ('user_6', 'Frank Lee', 'pdt.laprogram+6@gmail.com', 1, datetime('now'), datetime('now')), - ('user_7', 'Grace Wang', 'pdt.laprogram+7@gmail.com', 1, datetime('now'), datetime('now')), - ('user_8', 'Henry Zhao', 'pdt.laprogram+8@gmail.com', 1, datetime('now'), datetime('now')), - ('user_9', 'Iris Patel', 'pdt.laprogram+9@gmail.com', 1, datetime('now'), datetime('now')); + ('user_1', 'Alice Kim', 'pdt.laprogram+1@gmail.com', 0, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_2', 'Bob Chen', 'pdt.laprogram+2@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_3', 'Carol Davis', 'pdt.laprogram+3@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_4', 'Dan Nguyen', 'pdt.laprogram+4@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_5', 'Eve Park', 'pdt.laprogram+5@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_6', 'Frank Lee', 'pdt.laprogram+6@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_7', 'Grace Wang', 'pdt.laprogram+7@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_8', 'Henry Zhao', 'pdt.laprogram+8@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ('user_9', 'Iris Patel', 'pdt.laprogram+9@gmail.com', 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')); -- Course assignments: 3 users per course INSERT INTO course (userId, course_name, position) VALUES @@ -26,21 +26,21 @@ INSERT INTO course (userId, course_name, position) VALUES ('user_8', 'PHYSICS 1A', 'ret'), ('user_9', 'PHYSICS 1A', 'ped_lcc'); --- Sections -INSERT INTO section (id, course_name, section_name, day, time, location, ta_name, ta_email) VALUES - ('CS31-1A', 'CS 31', '1A', 'Monday', '9:00-9:50', 'Boelter 5249', 'John Smith', 'jsmith@ucla.edu'), - ('CS31-1B', 'CS 31', '1B', 'Tuesday', '10:00-10:50', 'Boelter 5249', 'John Smith', 'jsmith@ucla.edu'), - ('CS31-1C', 'CS 31', '1C', 'Wednesday', '11:00-11:50', 'Boelter 5249', 'John Smith', 'jsmith@ucla.edu'), - ('CS31-1D', 'CS 31', '1D', 'Thursday', '12:00-12:50', 'Boelter 5249', 'Sarah Jones', 'sjones@ucla.edu'), - ('MATH61-1A', 'MATH 61', '1A', 'Monday', '14:00-14:50', 'MS 5127', 'Mike Brown', 'mbrown@ucla.edu'), - ('MATH61-1B', 'MATH 61', '1B', 'Wednesday', '14:00-14:50', 'MS 5127', 'Mike Brown', 'mbrown@ucla.edu'), - ('MATH61-1C', 'MATH 61', '1C', 'Friday', '10:00-10:50', 'MS 5127', 'Lisa White', 'lwhite@ucla.edu'), - ('PHYS1A-1A', 'PHYSICS 1A','1A', 'Tuesday', '8:00-8:50', 'Knudsen 1220B', 'Tom Green', 'tgreen@ucla.edu'), - ('PHYS1A-1B', 'PHYSICS 1A','1B', 'Thursday', '8:00-8:50', 'Knudsen 1220B', 'Tom Green', 'tgreen@ucla.edu'), - ('PHYS1A-1C', 'PHYSICS 1A','1C', 'Friday', '13:00-13:50', 'Knudsen 1220B', 'Amy Taylor', 'ataylor@ucla.edu'); +-- Sections. day_of_week is ISO (1 = Monday); times are 'HH:MM' LA wall time. +INSERT INTO section (id, raw, course_name, section_name, day_of_week, start_time, end_time, location, ta_name, ta_email) VALUES + ('CS31-1A', 'CS31-1A', 'CS 31', '1A', 1, '09:00', '09:50', 'Boelter 5249', 'John Smith', 'jsmith@ucla.edu'), + ('CS31-1B', 'CS31-1B', 'CS 31', '1B', 2, '10:00', '10:50', 'Boelter 5249', 'John Smith', 'jsmith@ucla.edu'), + ('CS31-1C', 'CS31-1C', 'CS 31', '1C', 3, '11:00', '11:50', 'Boelter 5249', 'John Smith', 'jsmith@ucla.edu'), + ('CS31-1D', 'CS31-1D', 'CS 31', '1D', 4, '12:00', '12:50', 'Boelter 5249', 'Sarah Jones', 'sjones@ucla.edu'), + ('MATH61-1A', 'MATH61-1A', 'MATH 61', '1A', 1, '14:00', '14:50', 'MS 5127', 'Mike Brown', 'mbrown@ucla.edu'), + ('MATH61-1B', 'MATH61-1B', 'MATH 61', '1B', 3, '14:00', '14:50', 'MS 5127', 'Mike Brown', 'mbrown@ucla.edu'), + ('MATH61-1C', 'MATH61-1C', 'MATH 61', '1C', 5, '10:00', '10:50', 'MS 5127', 'Lisa White', 'lwhite@ucla.edu'), + ('PHYS1A-1A', 'PHYS1A-1A', 'PHYSICS 1A','1A', 2, '08:00', '08:50', 'Knudsen 1220B', 'Tom Green', 'tgreen@ucla.edu'), + ('PHYS1A-1B', 'PHYS1A-1B', 'PHYSICS 1A','1B', 4, '08:00', '08:50', 'Knudsen 1220B', 'Tom Green', 'tgreen@ucla.edu'), + ('PHYS1A-1C', 'PHYS1A-1C', 'PHYSICS 1A','1C', 5, '13:00', '13:50', 'Knudsen 1220B', 'Amy Taylor', 'ataylor@ucla.edu'); -- Section assignments (which LA works which section) -INSERT INTO section_assignment (la_id, full_section_name) VALUES +INSERT INTO section_assignment (la_id, section_id) VALUES ('user_1', 'CS31-1A'), ('user_1', 'CS31-1B'), ('user_2', 'CS31-1C'), diff --git a/laprogram/types/db.ts b/laprogram/types/db.ts index 19a947e..4e57ab8 100644 --- a/laprogram/types/db.ts +++ b/laprogram/types/db.ts @@ -20,21 +20,26 @@ export type Section = { section_id: string; course_name: string; section_name: string; - day: string; - time: string; // e.g. "9:00-9:50" + /** ISO weekday, 1 = Monday. Null if the section has never been synced. */ + day_of_week: number | null; + /** Wall-clock 'HH:MM' in LA. */ + start_time: string | null; + end_time: string | null; location: string; }; export type AvailabilityRow = { id: string; section_id: string; - time: string; // e.g. "9:10-9:40" week: number; + /** Instants. Null on rows created before the timestamp migration. */ + start_at: string | null; + end_at: string | null; status: "open" | "hidden" | "taken"; }; -/** Raw DB query result — includes week/day/time before API transformation. */ -export type ObservationAvailabilityRow = { +/** An open slot as the API returns it: instants, already resolved. */ +export type ObservationAvailability = { id: string; la_name: string; la_email: string; @@ -42,16 +47,15 @@ export type ObservationAvailabilityRow = { course_name: string; section_name: string; location: string; - week: string; - day: string; - time: string; + start_at: string; + end_at: string; }; -/** API response — week/day/time replaced with parsed datetimes. */ -export type ObservationAvailability = Omit< - ObservationAvailabilityRow, - "week" | "day" | "time" +/** The same slot client-side, after `hydrateDates`. */ +export type ObservationSlot = Omit< + ObservationAvailability, + "start_at" | "end_at" > & { - time_start: TZDate; - time_end: TZDate; + start_at: TZDate; + end_at: TZDate; };