From 67d4693071fac4cdfaebf29531aff74f2d22498a Mon Sep 17 00:00:00 2001 From: Gibson Bell <95596261+gbyo@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:39:21 -0400 Subject: [PATCH 1/5] Add clear-with-durations endpoint Checks everyone out through the existing clear path so every visit still gets its sheet event, then moves the checkout times the librarian corrected. Co-Authored-By: Claude Opus 5 --- src/admin-v6.ts | 183 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/admin-v6.ts diff --git a/src/admin-v6.ts b/src/admin-v6.ts new file mode 100644 index 0000000..07e8a2e --- /dev/null +++ b/src/admin-v6.ts @@ -0,0 +1,183 @@ +import adminApp from "./admin-v5"; +import { ensureVisitArchiveColumns } from "./library"; + +type ActiveVisitRow = { + id: number; + checked_in_at: string; +}; + +type Adjustment = { + visitId: number; + durationMinutes: number; +}; + +const MAX_ADJUSTMENTS = 200; +const MAX_DURATION_MINUTES = 1440; + +const API_HEADERS = { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", +}; + +export default { + async fetch(request, env, ctx): Promise { + const pathname = normalizePath(new URL(request.url).pathname); + if (pathname !== "/api/library/admin-clear-with-durations") { + return adminApp.fetch(request, env, ctx); + } + if (!isStaffAuthorized(request)) return json({ error: "Unauthorized." }, 401); + if (request.method !== "POST") return json({ error: "Method not allowed.", allowedMethods: ["POST"] }, 405); + + try { + await ensureVisitArchiveColumns(env); + return await handleClearWithDurations(request, env, ctx); + } catch (error) { + console.error(JSON.stringify({ event: "library_clear_with_durations_error", error: String(error) })); + return json({ error: "Could not check everyone out." }, 500); + } + }, + + async scheduled(controller, env, ctx): Promise { + return adminApp.scheduled(controller, env, ctx); + }, +} satisfies ExportedHandler; + +async function handleClearWithDurations(request: Request, env: Env, ctx: ExecutionContext): Promise { + const body = await readJsonBody(request); + if (!isRecord(body)) return json({ error: "Invalid request body." }, 400); + + if (body.adjustments !== undefined && body.adjustments !== null && !Array.isArray(body.adjustments)) { + return json({ error: "Invalid request body." }, 400); + } + + const adjustments = parseAdjustments(body.adjustments); + if (adjustments === null) { + return json({ error: "Each corrected visit needs a whole number of minutes between 0 and " + MAX_DURATION_MINUTES + "." }, 400); + } + if (adjustments.length > MAX_ADJUSTMENTS) { + return json({ error: "Too many visits were sent at once. Refresh and try again." }, 400); + } + + const checkedInAt = await loadActiveCheckInTimes(env, adjustments.map((a) => a.visitId)); + const now = Date.now(); + const updates: { visitId: number; checkedOutAt: string }[] = []; + + for (const adjustment of adjustments) { + const startedAt = checkedInAt.get(adjustment.visitId); + // A visit that was checked out or archived while the librarian was reviewing + // is simply skipped; the clear below still leaves the roster empty. + if (!startedAt) continue; + const startedMs = new Date(startedAt).getTime(); + if (!Number.isFinite(startedMs)) continue; + const checkedOutMs = startedMs + adjustment.durationMinutes * 60_000; + if (checkedOutMs > now) { + return json({ error: "A corrected duration cannot put the checkout time in the future." }, 400); + } + updates.push({ visitId: adjustment.visitId, checkedOutAt: new Date(checkedOutMs).toISOString() }); + } + + // Clear first so the existing checkout path runs for everyone and each visit + // gets its Google Sheets event, then move the checkout times that were corrected. + const cleared = await adminApp.fetch(clearRequest(request), env, ctx); + if (!cleared.ok) return cleared; + const clearedBody = (await cleared.json().catch(() => ({}))) as Record; + + if (updates.length > 0) { + await env.SIGNAGE_DB.batch( + updates.map((update) => + env.SIGNAGE_DB.prepare( + `UPDATE library_visits + SET checked_out_at = ? + WHERE id = ? AND archived_at IS NULL AND checked_out_at IS NOT NULL` + ).bind(update.checkedOutAt, update.visitId) + ) + ); + } + + return json({ ...clearedBody, ok: true, adjusted: updates.length }); +} + +function parseAdjustments(value: unknown): Adjustment[] | null { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) return null; + + const byVisitId = new Map(); + for (const entry of value) { + if (!isRecord(entry)) return null; + const visitId = Number(entry.visitId); + const durationMinutes = Number(entry.durationMinutes); + if (!Number.isInteger(visitId) || visitId < 1) return null; + if (!Number.isInteger(durationMinutes) || durationMinutes < 0 || durationMinutes > MAX_DURATION_MINUTES) return null; + byVisitId.set(visitId, { visitId, durationMinutes }); + } + return [...byVisitId.values()]; +} + +async function loadActiveCheckInTimes(env: Env, visitIds: number[]): Promise> { + const found = new Map(); + if (visitIds.length === 0) return found; + + const placeholders = visitIds.map(() => "?").join(","); + const rows = await env.SIGNAGE_DB.prepare( + `SELECT id, checked_in_at FROM library_visits + WHERE id IN (${placeholders}) AND checked_out_at IS NULL AND archived_at IS NULL` + ).bind(...visitIds).all(); + + for (const row of rows.results ?? []) found.set(row.id, row.checked_in_at); + return found; +} + +type IncomingRequest = Parameters>[0]; + +function clearRequest(request: Request): IncomingRequest { + const target = new URL(request.url); + target.pathname = "/api/library/clear"; + target.search = ""; + + const headers = new Headers(); + headers.set("Content-Type", "application/json; charset=utf-8"); + for (const name of ["CF-Access-Authenticated-User-Email", "Cf-Access-Jwt-Assertion", "CF-Ray"]) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + + return new Request(target.toString(), { + method: "POST", + headers, + body: JSON.stringify({ method: "clear_all" }), + }) as IncomingRequest; +} + +async function readJsonBody(request: Request): Promise { + if (!(request.headers.get("Content-Type") ?? "").toLowerCase().includes("application/json")) return {}; + try { + return await request.json(); + } catch { + return {}; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizePath(pathname: string): string { + return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} + +function isStaffAuthorized(request: Request): boolean { + if (isLocalRequest(request)) return true; + return Boolean( + request.headers.get("CF-Access-Authenticated-User-Email")?.trim() && + request.headers.get("Cf-Access-Jwt-Assertion")?.trim() + ); +} + +function isLocalRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return !request.headers.get("CF-Ray") || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function json(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { status, headers: API_HEADERS }); +} From f227719b37696e50d4b81f36a885e1cc28d48f81 Mon Sep 17 00:00:00 2001 From: Gibson Bell <95596261+gbyo@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:39:21 -0400 Subject: [PATCH 2/5] Add checkout reminder for long visits Check everyone out now asks about visits over 30 minutes when more than one student is affected, so forgotten checkouts can be retimed first. Co-Authored-By: Claude Opus 5 --- src/admin-ui-v8.ts | 95 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/admin-ui-v8.ts diff --git a/src/admin-ui-v8.ts b/src/admin-ui-v8.ts new file mode 100644 index 0000000..0e1d8f8 --- /dev/null +++ b/src/admin-ui-v8.ts @@ -0,0 +1,95 @@ +import { adminHtml as durationAdminHtml } from "./admin-ui-v7"; + +const LONG_VISIT_CSS = ` +#longvisitmodal .modal{width:min(560px,100%)}.lvbulk{display:flex;align-items:end;gap:8px;margin-top:14px;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--subtle)}.lvbulk .field{flex:0 0 130px}.lvbulknote{flex:1;color:var(--quiet);font-size:11px;line-height:1.35} +.lvlist{max-height:min(48vh,360px);overflow:auto;margin-top:12px;border:1px solid var(--line);border-radius:8px}.lvrow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 12px;border-bottom:1px solid var(--line)}.lvrow:last-child{border-bottom:0}.lvname{font-size:12.5px;font-weight:650}.lvmeta{margin-top:3px;color:var(--muted);font-size:11px}.lvfield{display:flex;align-items:center;gap:6px;color:var(--quiet);font-size:11px}.lvminutes{width:82px;text-align:right;font-variant-numeric:tabular-nums} +.lvhint{margin-top:10px;color:var(--muted);font-size:11.5px;line-height:1.4} +`; + +const LONG_VISIT_MODAL = ` +
+`; + +const LONG_VISIT_SCRIPT = ` +`; + +export function adminHtml(): string { + return durationAdminHtml() + .replace("", LONG_VISIT_CSS + "") + .replace('
', LONG_VISIT_MODAL + '
') + .replace("", LONG_VISIT_SCRIPT + ""); +} From 80c0012aca11a7ffc614ab2098c73419a6ada967 Mon Sep 17 00:00:00 2001 From: Gibson Bell <95596261+gbyo@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:39:21 -0400 Subject: [PATCH 3/5] Add bulk visit duration endpoint Validates the whole batch before writing so one stale row cannot leave the rest half applied. Co-Authored-By: Claude Opus 5 --- src/admin-v7.ts | 149 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/admin-v7.ts diff --git a/src/admin-v7.ts b/src/admin-v7.ts new file mode 100644 index 0000000..84a96e0 --- /dev/null +++ b/src/admin-v7.ts @@ -0,0 +1,149 @@ +import adminApp from "./admin-v6"; +import { ensureVisitArchiveColumns } from "./library"; + +type EditableVisitRow = { + id: number; + checked_in_at: string; + checked_out_at: string | null; + archived_at: string | null; +}; + +type Update = { + visitId: number; + durationMinutes: number; +}; + +const MAX_UPDATES = 200; +const MAX_DURATION_MINUTES = 1440; + +const API_HEADERS = { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", +}; + +export default { + async fetch(request, env, ctx): Promise { + const pathname = normalizePath(new URL(request.url).pathname); + if (pathname !== "/api/library/admin-update-visit-durations") { + return adminApp.fetch(request, env, ctx); + } + if (!isStaffAuthorized(request)) return json({ error: "Unauthorized." }, 401); + if (request.method !== "POST") return json({ error: "Method not allowed.", allowedMethods: ["POST"] }, 405); + + try { + await ensureVisitArchiveColumns(env); + return await handleUpdateVisitDurations(request, env); + } catch (error) { + console.error(JSON.stringify({ event: "library_visit_durations_update_error", error: String(error) })); + return json({ error: "Could not update those visit durations." }, 500); + } + }, + + async scheduled(controller, env, ctx): Promise { + return adminApp.scheduled(controller, env, ctx); + }, +} satisfies ExportedHandler; + +async function handleUpdateVisitDurations(request: Request, env: Env): Promise { + const body = await readJsonBody(request); + if (!isRecord(body) || !Array.isArray(body.updates)) return json({ error: "Invalid request body." }, 400); + + const updates = parseUpdates(body.updates); + if (updates === null) { + return json({ error: "Each duration must be a whole number of minutes between 0 and " + MAX_DURATION_MINUTES + "." }, 400); + } + if (updates.length === 0) return json({ error: "Select at least one visit to update." }, 400); + if (updates.length > MAX_UPDATES) return json({ error: "Select " + MAX_UPDATES + " visits or fewer at a time." }, 400); + + const existing = await loadVisits(env, updates.map((update) => update.visitId)); + const now = Date.now(); + const statements: D1PreparedStatement[] = []; + + // Everything is checked before anything is written, so a stale row in the + // selection cannot leave the rest of the batch half applied. + for (const update of updates) { + const visit = existing.get(update.visitId); + if (!visit) return json({ error: "One of those visits was not found. Refresh and try again." }, 404); + if (visit.archived_at !== null) return json({ error: "Restore archived visits before editing their duration." }, 409); + if (visit.checked_out_at === null) return json({ error: "Check these students out before editing their visit duration." }, 409); + + const checkedInMs = new Date(visit.checked_in_at).getTime(); + if (!Number.isFinite(checkedInMs)) throw new Error("Visit has an invalid check-in timestamp."); + const checkedOutMs = checkedInMs + update.durationMinutes * 60_000; + if (checkedOutMs > now) return json({ error: "A duration cannot put the checkout time in the future." }, 400); + + statements.push( + env.SIGNAGE_DB.prepare( + `UPDATE library_visits + SET checked_out_at = ? + WHERE id = ? AND archived_at IS NULL AND checked_out_at IS NOT NULL` + ).bind(new Date(checkedOutMs).toISOString(), update.visitId) + ); + } + + const results = await env.SIGNAGE_DB.batch(statements); + const updated = results.reduce((total, result) => total + (result.meta.changes ?? 0), 0); + if (updated !== updates.length) { + return json({ error: "Some of those visits changed before they could be updated. Refresh and try again." }, 409); + } + + return json({ ok: true, updated }); +} + +function parseUpdates(value: unknown[]): Update[] | null { + const byVisitId = new Map(); + for (const entry of value) { + if (!isRecord(entry)) return null; + const visitId = Number(entry.visitId); + const durationMinutes = Number(entry.durationMinutes); + if (!Number.isInteger(visitId) || visitId < 1) return null; + if (!Number.isInteger(durationMinutes) || durationMinutes < 0 || durationMinutes > MAX_DURATION_MINUTES) return null; + byVisitId.set(visitId, { visitId, durationMinutes }); + } + return [...byVisitId.values()]; +} + +async function loadVisits(env: Env, visitIds: number[]): Promise> { + const placeholders = visitIds.map(() => "?").join(","); + const rows = await env.SIGNAGE_DB.prepare( + `SELECT id, checked_in_at, checked_out_at, archived_at FROM library_visits WHERE id IN (${placeholders})` + ).bind(...visitIds).all(); + + const found = new Map(); + for (const row of rows.results ?? []) found.set(row.id, row); + return found; +} + +async function readJsonBody(request: Request): Promise { + if (!(request.headers.get("Content-Type") ?? "").toLowerCase().includes("application/json")) return {}; + try { + return await request.json(); + } catch { + return {}; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizePath(pathname: string): string { + return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} + +function isStaffAuthorized(request: Request): boolean { + if (isLocalRequest(request)) return true; + return Boolean( + request.headers.get("CF-Access-Authenticated-User-Email")?.trim() && + request.headers.get("Cf-Access-Jwt-Assertion")?.trim() + ); +} + +function isLocalRequest(request: Request): boolean { + const hostname = new URL(request.url).hostname; + return !request.headers.get("CF-Ray") || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} + +function json(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { status, headers: API_HEADERS }); +} From ebf644e609ce9afd14d30c034c49fe1dc667db8e Mon Sep 17 00:00:00 2001 From: Gibson Bell <95596261+gbyo@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:39:21 -0400 Subject: [PATCH 4/5] Add duration multi-select to history Select several visits and correct their durations together. Co-Authored-By: Claude Opus 5 --- src/admin-ui-v9.ts | 133 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/admin-ui-v9.ts diff --git a/src/admin-ui-v9.ts b/src/admin-ui-v9.ts new file mode 100644 index 0000000..9019ce8 --- /dev/null +++ b/src/admin-ui-v9.ts @@ -0,0 +1,133 @@ +import { adminHtml as longVisitAdminHtml } from "./admin-ui-v8"; + +const HISTORY_HEAD_OLD = `StudentReasonCheck inCheck outDurationCheckout`; +const HISTORY_HEAD_NEW = `StudentReasonCheck inCheck outDurationCheckout`; + +const HISTORY_SECTION_OLD = `
Loading visits…
`; +const HISTORY_SECTION_NEW = `
Loading visits…
`; + +const SELECTION_CSS = ` +th.selcol,td.selcell{width:1%;padding-right:0;text-align:center}.rowselect{width:16px;height:16px;min-height:0;margin:0;accent-color:var(--accent)}#selall[hidden]{display:none} +.selbar{display:flex;align-items:center;gap:10px;padding:9px 15px;border-bottom:1px solid var(--line);background:var(--subtle);font-size:12.5px}.selbar[hidden]{display:none}.selbar strong{margin-right:auto;font-size:12.5px} +#bulkdurationmodal .modal{width:min(560px,100%)} +`; + +const BULK_DURATION_MODAL = ` +
+`; + +const SELECTION_SCRIPT = ` +`; + +export function adminHtml(): string { + return longVisitAdminHtml() + .replace(HISTORY_HEAD_OLD, HISTORY_HEAD_NEW) + .replace(HISTORY_SECTION_OLD, HISTORY_SECTION_NEW) + .replace("", SELECTION_CSS + "") + .replace('
', BULK_DURATION_MODAL + '
') + .replace("", SELECTION_SCRIPT + ""); +} From b79cb97ab63925af771e4b436c42b1594748fa99 Mon Sep 17 00:00:00 2001 From: Gibson Bell <95596261+gbyo@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:39:21 -0400 Subject: [PATCH 5/5] Wire checkout reminder and bulk duration routes Co-Authored-By: Claude Opus 5 --- src/access-router.ts | 2 +- src/admin-ui.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/access-router.ts b/src/access-router.ts index e4d744f..a156b62 100644 --- a/src/access-router.ts +++ b/src/access-router.ts @@ -1,4 +1,4 @@ -import adminApp from "./admin-v5"; +import adminApp from "./admin-v7"; const MANAGE_PATH = "/library/manage"; const MANAGE_API_PATTERN = /^[a-z0-9-]+$/; diff --git a/src/admin-ui.ts b/src/admin-ui.ts index e72f6c6..f8ff98e 100644 --- a/src/admin-ui.ts +++ b/src/admin-ui.ts @@ -1 +1 @@ -export { adminHtml } from "./admin-ui-v7"; +export { adminHtml } from "./admin-ui-v9";