From 01874a67123df687355006a12ffda8c76066dae6 Mon Sep 17 00:00:00 2001 From: ScrippsSandboxMakerspace Date: Thu, 13 Aug 2026 11:34:25 -0700 Subject: [PATCH 01/26] Preserve deployed kiosk production state --- ESP-32/src/scanner.ino | 115 ++++-- Kiosk-v2/app/globals.css | 16 +- Kiosk-v2/app/page.tsx | 92 +++-- Kiosk-v2/bridge/app.py | 17 +- Kiosk-v2/bridge/apps_script_backend.py | 139 +++++++ Kiosk-v2/bridge/sheets_backend.py | 412 +++++++------------ Kiosk-v2/bridge/tests/test_sheets_backend.py | 373 ++++++----------- 7 files changed, 601 insertions(+), 563 deletions(-) create mode 100644 Kiosk-v2/bridge/apps_script_backend.py diff --git a/ESP-32/src/scanner.ino b/ESP-32/src/scanner.ino index 72d15df..35227c6 100644 --- a/ESP-32/src/scanner.ino +++ b/ESP-32/src/scanner.ino @@ -27,6 +27,14 @@ const int daylightOffset_sec = 0; #define GRAY 0x2104 Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST); + + +// Exact kiosk palette, converted from CSS hex to RGB565. +#define KIOSK_CREAM 0xF77C // #F2EEE3 +#define KIOSK_ORANGE 0xF483 // #F7931E +#define KIOSK_GRAY 0x73AF // #747678 +#define KIOSK_CYAN 0x1DD9 // #18B9C8 +#define KIOSK_NAVY 0x1127 // #13243C // PN532 params #define PN532_SS (21) @@ -96,28 +104,80 @@ void setup() { ////////////////////// DISPLAY ///////////////////// //////////////////////////////////////////////////////// +void drawDownArrow(int cx, int top, int width, int height, uint16_t color) { + int shaftWidth = width / 3; + int shoulder = top + height / 2; + tft.fillRect(cx - shaftWidth / 2, top, shaftWidth, height / 2 + 1, color); + tft.fillTriangle(cx - width / 2, shoulder, cx + width / 2, shoulder, + cx, top + height, color); +} + +void drawUpArrow(int cx, int top, int width, int height, uint16_t color) { + int shaftWidth = width / 3; + int shoulder = top + height / 2; + tft.fillTriangle(cx, top, cx - width / 2, shoulder, + cx + width / 2, shoulder, color); + tft.fillRect(cx - shaftWidth / 2, shoulder, shaftWidth, height / 2 + 1, color); +} + void homeScreen() { - tft.setFont(&FreeSerif24pt7b); - tft.fillScreen(ST77XX_BLACK); - tft.setCursor(20, 40); - tft.setTextColor(ST77XX_YELLOW); - tft.setTextSize(1); - tft.println("UC San Diego"); - tft.setCursor(20, 80); - tft.println("Makerspace"); - tft.fillRect(20, 45, tft.width()-95,5, ST77XX_BLUE); - tft.fillRect(tft.width()-52, 45, 20,5, ST77XX_BLUE); - //tft.drawLine(0, 35, tft.width()-100,30, ST77XX_BLUE); - //tft.drawLine(tft.width()-70, 35, tft.width()-30,30, ST77XX_BLUE); - - tft.setTextSize(1); - //tft.setCursor(0, 60); - tft.setCursor(20, 160); - - tft.print("Please Scan"); - tft.setCursor(20, 200); - - tft.print("Your ID card"); + tft.setFont(NULL); + tft.fillScreen(KIOSK_NAVY); + tft.setTextWrap(false); + tft.setTextColor(KIOSK_CREAM); + tft.setTextSize(4); + tft.setCursor(18, 18); + tft.print("TAP ID"); + drawDownArrow(160, 72, 124, 104, KIOSK_ORANGE); + tft.setTextSize(2); + tft.setCursor(82, 214); + tft.print("ON BLUE HAND"); +} + +void animateToLookUp() { + // Only redraw the text and arrow regions; the background never flashes. + tft.fillRect(0, 8, 320, 48, KIOSK_NAVY); + tft.fillRect(0, 205, 320, 35, KIOSK_NAVY); + + for (int frame = 0; frame < 5; frame++) { + int height = 104 - (frame * 23); + int width = 124 - (frame * 5); + tft.fillRect(85, 66, 150, 122, KIOSK_NAVY); + if (height > 12) { + drawDownArrow(160, 72 + (104 - height) / 2, width, height, KIOSK_ORANGE); + } else { + tft.fillRect(108, 126, 104, 10, KIOSK_ORANGE); + } + delay(45); + } + + for (int frame = 1; frame <= 5; frame++) { + int height = 10 + (frame * 19); + int width = 104 + (frame * 4); + tft.fillRect(85, 66, 150, 122, KIOSK_NAVY); + drawUpArrow(160, 126 - height / 2, width, height, KIOSK_CREAM); + delay(45); + } + + // Static hold: professional, attention-directing, and flicker-free. + tft.fillScreen(KIOSK_NAVY); + tft.fillTriangle(0, 0, 112, 0, 86, 34, KIOSK_ORANGE); + tft.setFont(NULL); + tft.setTextWrap(false); + tft.setTextColor(KIOSK_CREAM); + tft.setTextSize(2); + tft.setCursor(15, 9); + tft.print("NEXT"); + drawUpArrow(160, 42, 112, 94, KIOSK_CREAM); + tft.setTextColor(KIOSK_CREAM); + tft.setTextSize(4); + tft.setCursor(76, 146); + tft.print("LOOK UP"); + tft.fillRect(105, 188, 110, 4, KIOSK_ORANGE); + tft.setTextColor(KIOSK_ORANGE); + tft.setTextSize(2); + tft.setCursor(70, 211); + tft.print("FINISH CHECK-IN"); } @@ -140,14 +200,6 @@ void loop() { success = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength,100); if (success) { - tft.fillScreen(ST77XX_BLACK); - tft.setCursor(0, 30); - - tft.setFont(&FreeSerif24pt7b); - - tft.setTextSize(1); - tft.println("Found Card!\n"); - uint32_t szPos; for (szPos = 0; szPos < uidLength; szPos++) @@ -156,8 +208,9 @@ void loop() { //gmtime_r(&nowSecs, &timeinfo); Serial.println(String(uidStr)); - tft.setTextSize(1); - delay(500); + // The reader confirms only the scan; the kiosk confirms check-in. + animateToLookUp(); + delay(5000); do { versiondata = nfc.getFirmwareVersion(); nfc.SAMConfig(); diff --git a/Kiosk-v2/app/globals.css b/Kiosk-v2/app/globals.css index 16dbbf4..0e6cd85 100644 --- a/Kiosk-v2/app/globals.css +++ b/Kiosk-v2/app/globals.css @@ -461,13 +461,18 @@ input:focus { border-color: var(--orange); } .choice-grid button:hover, .choice-grid button.selected { background: var(--cream); color: var(--navy); } .onboarding-grid { display: grid; grid-template-columns: 1fr 210px; gap: clamp(30px, 4vw, 70px); align-items: center; margin: 28px 0; } -.onboarding-grid ol { margin: 30px 0 0; padding: 0; list-style: none; } -.onboarding-grid li { display: flex; gap: 18px; padding: 12px 0; border-top: 1px solid rgba(242,238,227,.4); font-size: 18px; } -.onboarding-grid li b { color: var(--orange); font-family: "Jost"; } +.onboarding-grid ol, .onboarding-primary ol { margin: 24px 0 0; padding: 0; list-style: none; } +.onboarding-grid li, .onboarding-primary li { display: flex; gap: 18px; padding: 10px 0; border-top: 1px solid rgba(242,238,227,.4); font-size: 18px; } +.onboarding-grid li b, .onboarding-primary li b { color: var(--orange); font-family: "Jost"; } +.onboarding-primary { display: grid; grid-template-columns: 318px 1fr; gap: clamp(32px, 5vw, 76px); align-items: center; margin: 26px 0 20px; } +.onboarding-fallback { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 22px; padding-top: 18px; border-top: 1px solid rgba(242,238,227,.35); } +.onboarding-fallback p { flex: 1 1 360px; margin: 0; line-height: 1.45; } +.onboarding-fallback .outline-action, .onboarding-fallback .quiet-action { width: auto; margin: 0; } .qr-placeholder { display: grid; grid-template-columns: repeat(8, 1fr); aspect-ratio: 1; padding: 14px; gap: 2px; background: var(--cream); } .qr-placeholder i { background: var(--navy); } .qr-placeholder i:nth-child(3n), .qr-placeholder i:nth-child(5n), .qr-placeholder i:nth-child(7n) { background: var(--cream); } .qr-code { width: 210px; padding: 14px; background: var(--cream); color: var(--navy); display: grid; place-items: center; gap: 10px; box-sizing: border-box; } +.qr-code-large { width: 318px; padding: 16px; } .qr-code svg { display: block; width: 100%; height: auto; } .qr-code span { font-family: "Jost"; font-size: 13px; font-weight: 800; letter-spacing: .14em; } @@ -626,3 +631,8 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } } + +.waiver-required-content { width: min(100%, 840px); display: grid; grid-template-columns: minmax(0, 1fr) 318px; align-items: center; gap: clamp(32px, 5vw, 72px); } +.waiver-required-content .button-row { margin-top: 28px; } +.waiver-qr { justify-self: end; text-align: center; } +@media (max-width: 900px) { .waiver-required-content { grid-template-columns: 1fr 250px; gap: 24px; } .waiver-required-content .qr-code-large { width: 250px; } } diff --git a/Kiosk-v2/app/page.tsx b/Kiosk-v2/app/page.tsx index 46877e4..5f8e5ba 100644 --- a/Kiosk-v2/app/page.tsx +++ b/Kiosk-v2/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { QRCodeSVG } from "qrcode.react"; type Screen = @@ -29,6 +29,7 @@ type Announcement = { }; const REGISTRATION_URL = process.env.NEXT_PUBLIC_REGISTRATION_URL?.trim() || ""; +const WAIVER_URL = process.env.NEXT_PUBLIC_WAIVER_URL?.trim() || ""; type ScannerStatus = "demo" | "connecting" | "connected" | "disconnected"; @@ -82,6 +83,20 @@ function Arrow({ direction = "right" }: { direction?: "right" | "left" }) { return ; } +function ordinal(value: number) { + const modulo100 = value % 100; + const suffix = modulo100 >= 11 && modulo100 <= 13 + ? "th" + : value % 10 === 1 + ? "st" + : value % 10 === 2 + ? "nd" + : value % 10 === 3 + ? "rd" + : "th"; + return String(value) + suffix; +} + export default function Home() { const [screen, setScreen] = useState("home"); const [demoOpen, setDemoOpen] = useState(false); @@ -305,7 +320,7 @@ export default function Home() { setScreen("reading"); } - function reset() { + const reset = useCallback(() => { cardDetectedAtRef.current = null; setScannerResult(null); setWelcomeName("Sandbox member"); @@ -320,7 +335,15 @@ export default function Home() { setLinkError(""); setStaffCardDetected(false); setDemoOpen(false); - } + }, []); + + useEffect(() => { + const returnHome = (event: KeyboardEvent) => { + if (event.key === "Escape") reset(); + }; + window.addEventListener("keydown", returnHome); + return () => window.removeEventListener("keydown", returnHome); + }, [reset]); async function submitPid(event: FormEvent) { event.preventDefault(); @@ -538,18 +561,26 @@ export default function Home() { )} {screen === "waiver-required" && ( -
+
+

WAIVER REQUIRED

-

One step before
you check in.

-

We found your Sandbox account, but not a current waiver. Please ask staff for help before using the space.

+

Sign the waiver
on your phone.

+

We found your Sandbox account, but no signed waiver is on file. Scan the code, complete the DocuSign waiver, then return here and try again.

- )} + {WAIVER_URL && ( +
+ + SCAN TO SIGN THE WAIVER +
+ )} +
+ )} - {screen === "backend-error" && ( + {screen === "backend-error" && (

CHECK-IN NOT RECORDED

Please check in
with staff.

@@ -666,31 +697,36 @@ export default function Home() { )} {screen === "new-here" && ( -
- -

WELCOME TO THE SANDBOX

-

Make something
unexpected.

-
+
+ +

WELCOME TO THE SANDBOX

+

Start on
your phone.

+ {REGISTRATION_URL ? ( +
+
+ + SCAN TO CREATE YOUR ACCOUNT +
-

Scan with your phone to submit your Sandbox profile. After the waiver, ask staff to finish setup and connect your card.

+

Use your phone to create your account and complete the liability waiver. It takes about three minutes.

    -
  1. 01Submit your profile
  2. -
  3. 02Complete the liability waiver
  4. -
  5. 03Ask staff to activate your account
  6. +
  7. 01Create your Sandbox profile
  8. +
  9. 02Complete the waiver on your phone
  10. +
  11. 03Return here and tap your card
- {REGISTRATION_URL &&
- - SCAN TO JOIN -
}
- {REGISTRATION_URL - ? Open the registration form - :

The registration link is not configured yet. Please ask Sandbox staff for help.

} - + ) : ( +

Phone registration is not configured yet. Please ask Sandbox staff for help.

+ )} +
+

No phone nearby? You can create the account on this kiosk. The waiver must still be completed from a phone or another device.

+ {REGISTRATION_URL && Create an account on this kiosk} +
- )} - +
+ )} + {screen === "success" && (
@@ -707,7 +743,7 @@ export default function Home() {
TODAY {timeLabel} - {visitCount === null ? "CHECKED IN" : `VISIT DAY ${visitCount}`} + {visitCount === null ? "CHECKED IN" : ordinal(visitCount) + " Visit"}

Returning home in {countdown} seconds

diff --git a/Kiosk-v2/bridge/app.py b/Kiosk-v2/bridge/app.py index 7de30f1..76b1922 100644 --- a/Kiosk-v2/bridge/app.py +++ b/Kiosk-v2/bridge/app.py @@ -17,6 +17,7 @@ from serial.tools import list_ports from scanner_protocol import DuplicateGuard, normalize_uid +from apps_script_backend import AppsScriptCheckInBackend from sheets_backend import CheckInResult, GoogleSheetsProvider, SheetsCheckInBackend @@ -33,22 +34,24 @@ for value in os.getenv("CARD_LINK_STAFF_IDS", "").split(",") if value.strip() } -if BACKEND_MODE not in {"demo", "sheets"}: - raise RuntimeError("SCANNER_CHECKIN_BACKEND must be 'demo' or 'sheets'") +if BACKEND_MODE not in {"demo", "sheets", "apps-script"}: + raise RuntimeError("SCANNER_CHECKIN_BACKEND must be 'demo', 'sheets', or 'apps-script'") -def build_checkin_backend() -> SheetsCheckInBackend | None: +def build_checkin_backend() -> SheetsCheckInBackend | AppsScriptCheckInBackend | None: # Simulation is deliberately write-free, even if a stale environment file # also asks for the Sheets backend. if SIMULATION_ENABLED or BACKEND_MODE == "demo": return None + if BACKEND_MODE == "apps-script": + return AppsScriptCheckInBackend.from_environment() return SheetsCheckInBackend(GoogleSheetsProvider.from_environment()) class BridgeState: def __init__( self, - backend: SheetsCheckInBackend | None = None, + backend: SheetsCheckInBackend | AppsScriptCheckInBackend | None = None, designated_card_link_staff_ids: set[str] | None = None, ) -> None: self.reader_status = "simulation" if SIMULATION_ENABLED else "searching" @@ -246,10 +249,10 @@ async def warm_backend() -> None: timings = await asyncio.to_thread(STATE.backend.warm_up) except Exception: STATE.backend_ready = False - LOGGER.exception("Sheets cache warm-up failed; the first scan will retry") + LOGGER.exception("Check-in backend warm-up failed; the first scan will retry") return STATE.backend_ready = True - LOGGER.info("Sheets cache warm-up complete; stages=%s", timings) + LOGGER.info("Check-in backend warm-up complete; stages=%s", timings) @asynccontextmanager @@ -281,7 +284,7 @@ async def health() -> dict[str, Any]: "reader": STATE.reader_status, "port": STATE.reader_port, "clients": len(STATE.clients), - "backend": "demo" if STATE.backend is None else "sheets", + "backend": "demo" if STATE.backend is None else BACKEND_MODE, "backend_ready": STATE.backend_ready, } diff --git a/Kiosk-v2/bridge/apps_script_backend.py b/Kiosk-v2/bridge/apps_script_backend.py new file mode 100644 index 0000000..fd97e7f --- /dev/null +++ b/Kiosk-v2/bridge/apps_script_backend.py @@ -0,0 +1,139 @@ +"""Private Apps Script API backend for the kiosk bridge. + +Raw card UIDs never leave the Raspberry Pi. They are converted to keyed HMAC +digests before this client contacts the UCSD-owned Apps Script deployment. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from pathlib import Path +import time +from typing import Any +from urllib.request import Request, urlopen + +from sheets_backend import CheckInResult, normalize_card_uid, required_secret + + +def _required_value(name: str) -> str: + value = os.getenv(name, "").strip() + if not value: + raise RuntimeError(f"{name} is required") + return value + + +def _required_file_secret(env_name: str) -> str: + path = _required_value(env_name) + value = Path(path).read_text(encoding="utf-8").strip() + if not value: + raise RuntimeError(f"{env_name} points to an empty file") + return value + + +class AppsScriptCheckInBackend: + def __init__( + self, + url: str, + api_key: str, + card_hmac_secret: str, + timeout_seconds: float = 20, + ) -> None: + self.url = url + self.api_key = api_key + self.card_hmac_secret = card_hmac_secret + self.timeout_seconds = timeout_seconds + + @classmethod + def from_environment(cls) -> "AppsScriptCheckInBackend": + return cls( + url=_required_value("KIOSK_APPS_SCRIPT_URL"), + api_key=_required_file_secret("KIOSK_API_KEY_FILE"), + card_hmac_secret=required_secret(), + timeout_seconds=float(os.getenv("KIOSK_APPS_SCRIPT_TIMEOUT_SECONDS", "20")), + ) + + def card_digest(self, card_uid: str) -> str: + return hmac.new( + self.card_hmac_secret.encode("utf-8"), + normalize_card_uid(card_uid).encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + def _request(self, action: str, **values: Any) -> tuple[dict[str, Any], int]: + started_at = time.monotonic() + body = json.dumps({"apiKey": self.api_key, "action": action, **values}).encode("utf-8") + request = Request( + self.url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request, timeout=self.timeout_seconds) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, dict): + raise RuntimeError("Apps Script returned an invalid response") + return payload, round((time.monotonic() - started_at) * 1000) + + @staticmethod + def _result(payload: dict[str, Any], request_ms: int) -> CheckInResult: + return CheckInResult( + outcome=str(payload.get("outcome") or "backend_error"), + display_name=payload.get("displayName"), + message=str(payload.get("message") or ""), + visit_count=payload.get("visitCount"), + timings_ms={"apps_script": request_ms}, + ) + + def warm_up(self) -> dict[str, int]: + payload, request_ms = self._request("status") + if not payload.get("ok") or payload.get("outcome") != "ready": + raise RuntimeError("Apps Script kiosk API is not ready") + return {"apps_script": request_ms} + + def check_in(self, card_uid: str) -> CheckInResult: + payload, request_ms = self._request("check_in_card", cardDigest=self.card_digest(card_uid)) + return self._result(payload, request_ms) + + def _identifier_request(self, action: str, identifier: str) -> tuple[dict[str, Any], int]: + payload, request_ms = self._request(action, identifier=identifier) + # Google Sheets may store an employee ID such as 000023 as 23. + # Try the exact ID first, then retry only a numeric leading-zero variant. + if ( + payload.get("outcome") == "unknown_identifier" + and identifier.isdigit() + and len(identifier) > 1 + and identifier.startswith("0") + ): + normalized = identifier.lstrip("0") or "0" + retry_payload, retry_ms = self._request(action, identifier=normalized) + if retry_payload.get("outcome") != "unknown_identifier": + return retry_payload, request_ms + retry_ms + return payload, request_ms + + def check_in_identifier(self, identifier: str) -> CheckInResult: + payload, request_ms = self._identifier_request("check_in_identifier", identifier) + return self._result(payload, request_ms) + + def prepare_card_link(self, identifier: str) -> CheckInResult: + payload, request_ms = self._identifier_request("prepare_card_link", identifier) + return self._result(payload, request_ms) + + def link_card( + self, + identifier: str, + member_uid: str, + staff_uid: str, + designated_ids: set[str], + ) -> CheckInResult: + del designated_ids # Authorization is maintained centrally in Staff Access. + payload, request_ms = self._request( + "link_card", + identifier=identifier, + memberDigest=self.card_digest(member_uid), + memberLastFour=normalize_card_uid(member_uid)[-4:], + staffDigest=self.card_digest(staff_uid), + ) + return self._result(payload, request_ms) diff --git a/Kiosk-v2/bridge/sheets_backend.py b/Kiosk-v2/bridge/sheets_backend.py index 60f7799..36c2366 100644 --- a/Kiosk-v2/bridge/sheets_backend.py +++ b/Kiosk-v2/bridge/sheets_backend.py @@ -1,18 +1,23 @@ -"""Transitional Google Sheets check-in backend for the local kiosk bridge. +"""Normalized Google Sheets check-in backend for the local kiosk bridge. -The browser receives display-safe outcomes only. Raw card UIDs stay inside this -local process and the existing activity Sheet. +Raw card UIDs stay inside this local process. The production database stores +only keyed card digests and short display suffixes. Waivers remain read-only in +the existing Waiver Signatures SIO spreadsheet. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime +import hashlib +import hmac import logging import os +from pathlib import Path from threading import Lock import time from typing import Any, Callable, Protocol +from uuid import uuid4 LOGGER = logging.getLogger("sandbox-scanner.sheets") @@ -29,14 +34,11 @@ class CheckInResult: class SheetsProvider(Protocol): def user_records(self) -> list[dict[str, Any]]: ... - def waiver_records(self) -> list[dict[str, Any]]: ... - def activity_rows(self) -> list[list[Any]]: ... - def append_activity(self, row: list[Any]) -> None: ... - def update_user_card(self, identifier: str, card_uid: str) -> dict[str, Any]: ... + def card_digest(self, card_uid: str) -> str: ... def normalize_person_id(value: Any) -> str: @@ -48,32 +50,48 @@ def normalize_email(value: Any) -> str: return str(value or "").strip().lower() +def normalize_card_uid(value: Any) -> str: + return str(value or "").strip().upper() + + def elapsed_ms(started_at: float) -> int: return round((time.monotonic() - started_at) * 1000) +def required_secret() -> str: + inline = os.getenv("CARD_HMAC_SECRET", "").strip() + secret_file = os.getenv("CARD_HMAC_SECRET_FILE", "").strip() + if inline: + return inline + if secret_file: + return Path(secret_file).read_text(encoding="utf-8").strip() + raise RuntimeError("CARD_HMAC_SECRET or CARD_HMAC_SECRET_FILE is required") + + class GoogleSheetsProvider: - """Lazy, thread-safe access to the three existing Scripps Sheets.""" + """Lazy, thread-safe access to the normalized database and waiver source.""" def __init__( self, credentials_path: str, - user_sheet_name: str, + database_id: str, waiver_sheet_name: str, - activity_sheet_url: str, + card_hmac_secret: str, cache_seconds: int = 300, activity_cache_seconds: int = 3600, ) -> None: self.credentials_path = credentials_path - self.user_sheet_name = user_sheet_name + self.database_id = database_id self.waiver_sheet_name = waiver_sheet_name - self.activity_sheet_url = activity_sheet_url + self.card_hmac_secret = card_hmac_secret self.cache_seconds = cache_seconds self.activity_cache_seconds = activity_cache_seconds self._lock = Lock() - self._user_sheet: Any = None + self._people_sheet: Any = None + self._identifiers_sheet: Any = None + self._cards_sheet: Any = None + self._visits_sheet: Any = None self._waiver_sheet: Any = None - self._activity_sheet: Any = None self._users: list[dict[str, Any]] | None = None self._waivers: list[dict[str, Any]] | None = None self._activity_rows: list[list[Any]] | None = None @@ -83,65 +101,96 @@ def __init__( @classmethod def from_environment(cls) -> "GoogleSheetsProvider": credentials_path = os.getenv("SHEETS_CREDENTIALS_PATH", "").strip() - activity_sheet_url = os.getenv("SHEETS_ACTIVITY_URL", "").strip() - if not credentials_path or not activity_sheet_url: - raise RuntimeError( - "SHEETS_CREDENTIALS_PATH and SHEETS_ACTIVITY_URL are required" - ) + database_id = os.getenv("SHEETS_DATABASE_ID", "").strip() + if not credentials_path or not database_id: + raise RuntimeError("SHEETS_CREDENTIALS_PATH and SHEETS_DATABASE_ID are required") return cls( credentials_path=credentials_path, - user_sheet_name=os.getenv("SHEETS_USER_DB_NAME", "User Database SIO"), - waiver_sheet_name=os.getenv( - "SHEETS_WAIVER_DB_NAME", "Waiver Signatures SIO" - ), - activity_sheet_url=activity_sheet_url, + database_id=database_id, + waiver_sheet_name=os.getenv("SHEETS_WAIVER_DB_NAME", "Waiver Signatures SIO"), + card_hmac_secret=required_secret(), cache_seconds=int(os.getenv("SHEETS_CACHE_SECONDS", "300")), - activity_cache_seconds=int( - os.getenv("SHEETS_ACTIVITY_CACHE_SECONDS", "3600") - ), + activity_cache_seconds=int(os.getenv("SHEETS_ACTIVITY_CACHE_SECONDS", "3600")), ) + def card_digest(self, card_uid: str) -> str: + return hmac.new( + self.card_hmac_secret.encode("utf-8"), + normalize_card_uid(card_uid).encode("utf-8"), + hashlib.sha256, + ).hexdigest() + def _connect(self) -> None: - if self._activity_sheet is not None: + if self._visits_sheet is not None: return import gspread client = gspread.service_account(filename=self.credentials_path) - self._user_sheet = client.open(self.user_sheet_name).sheet1 + database = client.open_by_key(self.database_id) + self._people_sheet = database.worksheet("People") + self._identifiers_sheet = database.worksheet("Identifiers") + self._cards_sheet = database.worksheet("Cards") + self._visits_sheet = database.worksheet("Visits") self._waiver_sheet = client.open(self.waiver_sheet_name).sheet1 - self._activity_sheet = client.open_by_url(self.activity_sheet_url).sheet1 def _refresh_people_if_needed(self) -> None: now = time.monotonic() if self._users is not None and now < self._cache_expires_at: return self._connect() - self._users = self._user_sheet.get_all_records(numericise_ignore=["all"]) - self._waivers = self._waiver_sheet.get_all_records( - numericise_ignore=["all"] - ) + people = self._people_sheet.get_all_records(numericise_ignore=["all"]) + identifiers = self._identifiers_sheet.get_all_records(numericise_ignore=["all"]) + cards = self._cards_sheet.get_all_records(numericise_ignore=["all"]) + self._waivers = self._waiver_sheet.get_all_records(numericise_ignore=["all"]) + identifiers_by_person: dict[str, list[dict[str, Any]]] = {} + cards_by_person: dict[str, list[dict[str, Any]]] = {} + for record in identifiers: + if str(record.get("Active", "")).lower() not in {"true", "1"}: + continue + identifiers_by_person.setdefault(str(record.get("Person ID", "")), []).append(record) + for record in cards: + if str(record.get("Status", "")).strip().lower() != "active": + continue + cards_by_person.setdefault(str(record.get("Person ID", "")), []).append(record) + users = [] + for person in people: + if str(person.get("Status", "")).strip().lower() != "active": + continue + person_id = str(person.get("Person ID", "")).strip() + person_identifiers = identifiers_by_person.get(person_id, []) + identity = next((r for r in person_identifiers if str(r.get("Type", "")).lower() != "email" and bool(r.get("Primary"))), None) + if identity is None: + identity = next((r for r in person_identifiers if str(r.get("Type", "")).lower() != "email"), None) + email_record = next((r for r in person_identifiers if str(r.get("Type", "")).lower() == "email" and bool(r.get("Primary"))), None) + person_cards = cards_by_person.get(person_id, []) + card = person_cards[-1] if person_cards else {} + users.append({ + "Person ID": person_id, + "Name": str(person.get("Display Name", "")).strip(), + "Student ID": str((identity or {}).get("Normalized Value", "")).strip(), + "Email Address": str(person.get("Primary Email", "") or (email_record or {}).get("Normalized Value", "")).strip(), + "Card Digest": str(card.get("Card Digest", "")).strip().lower(), + }) + self._users = users self._cache_expires_at = now + self.cache_seconds def _refresh_activity_if_needed(self) -> None: now = time.monotonic() - if ( - self._activity_rows is not None - and now < self._activity_cache_expires_at - ): + if self._activity_rows is not None and now < self._activity_cache_expires_at: return self._connect() - self._activity_rows = self._activity_sheet.get_all_values() + self._activity_rows = self._visits_sheet.get_all_values() self._activity_cache_expires_at = now + self.activity_cache_seconds def user_records(self) -> list[dict[str, Any]]: with self._lock: self._refresh_people_if_needed() - return list(self._users or []) + return [dict(record) for record in self._users or []] def waiver_records(self) -> list[dict[str, Any]]: with self._lock: self._refresh_people_if_needed() - return list(self._waivers or []) + return [dict(record) for record in self._waivers or []] def activity_rows(self) -> list[list[Any]]: with self._lock: @@ -151,322 +200,173 @@ def activity_rows(self) -> list[list[Any]]: def append_activity(self, row: list[Any]) -> None: with self._lock: self._connect() - self._activity_sheet.append_row(row) + self._visits_sheet.append_row(row, value_input_option="USER_ENTERED") if self._activity_rows is not None: self._activity_rows.append(list(row)) - self._activity_cache_expires_at = ( - time.monotonic() + self.activity_cache_seconds - ) + self._activity_cache_expires_at = time.monotonic() + self.activity_cache_seconds def update_user_card(self, identifier: str, card_uid: str) -> dict[str, Any]: - """Attach a card to exactly one existing user and refresh the local cache.""" normalized_identifier = normalize_person_id(identifier) - normalized_uid = card_uid.strip().upper() + digest = self.card_digest(card_uid) + normalized_uid = normalize_card_uid(card_uid) with self._lock: self._refresh_people_if_needed() users = self._users or [] - matches = [ - (index, record) - for index, record in enumerate(users) - if normalize_person_id(record.get("Student ID")) == normalized_identifier - ] + matches = [record for record in users if normalize_person_id(record.get("Student ID")) == normalized_identifier] if len(matches) != 1: raise ValueError("The account could not be identified uniquely.") - if any( - str(record.get("Card UUID", "")).strip().upper() == normalized_uid - for record in users - ): + if any(str(record.get("Card Digest", "")).lower() == digest for record in users): raise ValueError("That card is already connected to an account.") - - index, record = matches[0] - existing_uid = str(record.get("Card UUID", "")).strip() - if existing_uid: + record = matches[0] + if str(record.get("Card Digest", "")).strip(): raise ValueError("That account already has a connected card.") - self._connect() - headers = self._user_sheet.row_values(1) - try: - card_column = headers.index("Card UUID") + 1 - except ValueError as error: - raise RuntimeError("The user database has no Card UUID column.") from error - self._user_sheet.update_cell(index + 2, card_column, normalized_uid) - record["Card UUID"] = normalized_uid + self._cards_sheet.append_row([ + "card_" + uuid4().hex, + record["Person ID"], + digest, + normalized_uid[-4:], + "Active", + datetime.now().isoformat(timespec="seconds"), + "", + "Kiosk v2 staff link", + "", + ], value_input_option="USER_ENTERED") + record["Card Digest"] = digest self._cache_expires_at = time.monotonic() + self.cache_seconds return dict(record) class SheetsCheckInBackend: - def __init__( - self, - provider: SheetsProvider, - now: Callable[[], float] = time.time, - local_datetime: Callable[[], datetime] = datetime.now, - ) -> None: + def __init__(self, provider: SheetsProvider, now: Callable[[], float] = time.time, local_datetime: Callable[[], datetime] = datetime.now) -> None: self.provider = provider self.now = now self.local_datetime = local_datetime def warm_up(self) -> dict[str, int]: - """Load the read-heavy Sheets data before the kiosk accepts a card.""" total_started = time.monotonic() timings: dict[str, int] = {} - - stage_started = time.monotonic() - self.provider.user_records() - timings["users"] = elapsed_ms(stage_started) - - stage_started = time.monotonic() - self.provider.waiver_records() - timings["waivers"] = elapsed_ms(stage_started) - - stage_started = time.monotonic() - self.provider.activity_rows() - timings["activity"] = elapsed_ms(stage_started) + for label, operation in (("users", self.provider.user_records), ("waivers", self.provider.waiver_records), ("activity", self.provider.activity_rows)): + stage_started = time.monotonic() + operation() + timings[label] = elapsed_ms(stage_started) timings["total"] = elapsed_ms(total_started) return timings def check_in(self, uid: str) -> CheckInResult: total_started = time.monotonic() timings: dict[str, int] = {} - normalized_uid = uid.strip().upper() - + digest = self.provider.card_digest(uid) stage_started = time.monotonic() - users = self.provider.user_records() - user = next( - ( - record - for record in users - if str(record.get("Card UUID", "")).strip().upper() - == normalized_uid - ), - None, - ) + user = next((record for record in self.provider.user_records() if str(record.get("Card Digest", "")).lower() == digest), None) timings["user_lookup"] = elapsed_ms(stage_started) if user is None: timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="unknown_card", - message="This card is not connected to a Sandbox account.", - timings_ms=timings, - ) - - return self._check_in_user(user, normalized_uid, total_started, timings) + return CheckInResult(outcome="unknown_card", message="This card is not connected to a Sandbox account.", timings_ms=timings) + return self._check_in_user(user, total_started, timings) def check_in_identifier(self, identifier: str) -> CheckInResult: total_started = time.monotonic() timings: dict[str, int] = {} normalized_identifier = normalize_person_id(identifier) - stage_started = time.monotonic() - users = self.provider.user_records() - matches = [ - record - for record in users - if normalized_identifier - and normalize_person_id(record.get("Student ID")) == normalized_identifier - ] + matches = [record for record in self.provider.user_records() if normalized_identifier and normalize_person_id(record.get("Student ID")) == normalized_identifier] timings["user_lookup"] = elapsed_ms(stage_started) if not matches: timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="unknown_identifier", - message="We could not find that PID or employee ID.", - timings_ms=timings, - ) + return CheckInResult(outcome="unknown_identifier", message="We could not find that PID or employee ID.", timings_ms=timings) if len(matches) > 1: timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="backend_error", - message="More than one account uses that identifier. Please see staff.", - timings_ms=timings, - ) - - user = matches[0] - card_uid = str(user.get("Card UUID", "")).strip().upper() - activity_identifier = card_uid or normalized_identifier.upper() - return self._check_in_user( - user, - activity_identifier, - total_started, - timings, - ) + return CheckInResult(outcome="backend_error", message="More than one account uses that identifier. Please see staff.", timings_ms=timings) + return self._check_in_user(matches[0], total_started, timings) def prepare_card_link(self, identifier: str) -> CheckInResult: - """Confirm that a staff-assisted card link has one eligible target.""" normalized_identifier = normalize_person_id(identifier) - matches = [ - record - for record in self.provider.user_records() - if normalized_identifier - and normalize_person_id(record.get("Student ID")) == normalized_identifier - ] + matches = [record for record in self.provider.user_records() if normalized_identifier and normalize_person_id(record.get("Student ID")) == normalized_identifier] if not matches: - return CheckInResult( - outcome="unknown_identifier", - message="We could not find that PID or employee ID.", - ) + return CheckInResult(outcome="unknown_identifier", message="We could not find that PID or employee ID.") if len(matches) > 1: - return CheckInResult( - outcome="card_link_error", - message="More than one account uses that identifier. Please see an administrator.", - ) + return CheckInResult(outcome="card_link_error", message="More than one account uses that identifier. Please see an administrator.") target = matches[0] - if str(target.get("Card UUID", "")).strip(): - return CheckInResult( - outcome="card_link_error", - message="That account already has a connected card.", - ) - return CheckInResult( - outcome="link_ready", - display_name=str(target.get("Name", "")).strip() or "Sandbox member", - message="Ask designated staff to tap their own card.", - ) + if str(target.get("Card Digest", "")).strip(): + return CheckInResult(outcome="card_link_error", message="That account already has a connected card.") + return CheckInResult(outcome="link_ready", display_name=str(target.get("Name", "")).strip() or "Sandbox member", message="Ask designated staff to tap their own card.") - def link_card( - self, - identifier: str, - card_uid: str, - staff_card_uid: str, - designated_staff_ids: set[str], - ) -> CheckInResult: - """Authorize a card link with a designated staff member's own card.""" + def link_card(self, identifier: str, card_uid: str, staff_card_uid: str, designated_staff_ids: set[str]) -> CheckInResult: total_started = time.monotonic() timings: dict[str, int] = {} normalized_identifier = normalize_person_id(identifier) - normalized_card_uid = card_uid.strip().upper() - normalized_staff_uid = staff_card_uid.strip().upper() - + member_digest = self.provider.card_digest(card_uid) + staff_digest = self.provider.card_digest(staff_card_uid) stage_started = time.monotonic() users = self.provider.user_records() - staff = next( - ( - record - for record in users - if str(record.get("Card UUID", "")).strip().upper() - == normalized_staff_uid - ), - None, - ) + staff = next((record for record in users if str(record.get("Card Digest", "")).lower() == staff_digest), None) staff_id = normalize_person_id(staff.get("Student ID")) if staff else "" allowed_staff = {normalize_person_id(value) for value in designated_staff_ids} timings["staff_lookup"] = elapsed_ms(stage_started) - if not staff or not staff_id or staff_id not in allowed_staff: - timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="staff_unauthorized", - message="That card is not authorized to connect member cards.", - timings_ms=timings, - ) - if normalized_staff_uid == normalized_card_uid: + if not staff or not staff_id or staff_id not in allowed_staff or staff_digest == member_digest: timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="staff_unauthorized", - message="Use the designated staff member's own card to approve this link.", - timings_ms=timings, - ) - + return CheckInResult(outcome="staff_unauthorized", message="That card is not authorized to connect member cards.", timings_ms=timings) stage_started = time.monotonic() try: - target = self.provider.update_user_card( - normalized_identifier, - normalized_card_uid, - ) + target = self.provider.update_user_card(normalized_identifier, card_uid) except ValueError as error: timings["card_update"] = elapsed_ms(stage_started) timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="card_link_error", - message=str(error), - timings_ms=timings, - ) + return CheckInResult(outcome="card_link_error", message=str(error), timings_ms=timings) timings["card_update"] = elapsed_ms(stage_started) - display_name = str(target.get("Name", "")).strip() or "Sandbox member" staff_name = str(staff.get("Name", "")).strip() or "Designated staff" - local_now = self.local_datetime() self.provider.append_activity([ - local_now.strftime("%m/%d/%Y %H:%M:%S"), - int(self.now()), - display_name, - normalized_card_uid, + "visit_" + uuid4().hex, + target.get("Person ID", ""), + self.local_datetime().isoformat(timespec="seconds"), "Card Linked", staff_name, "", "", + "Kiosk v2", + "", ]) timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="card_linked", - display_name=display_name, - message="Card connected. The member can now check in.", - timings_ms=timings, - ) - - def _check_in_user( - self, - user: dict[str, Any], - activity_identifier: str, - total_started: float, - timings: dict[str, int], - ) -> CheckInResult: + return CheckInResult(outcome="card_linked", display_name=display_name, message="Card connected. The member can now check in.", timings_ms=timings) + def _check_in_user(self, user: dict[str, Any], total_started: float, timings: dict[str, int]) -> CheckInResult: user_id = normalize_person_id(user.get("Student ID")) user_email = normalize_email(user.get("Email Address")) stage_started = time.monotonic() waivers = self.provider.waiver_records() - waiver_found = any( - ( - bool(user_id) - and normalize_person_id(waiver.get("A_Number")) == user_id - ) - or ( - bool(user_email) - and normalize_email(waiver.get("Email")) == user_email - ) - for waiver in waivers - ) + waiver_found = any((bool(user_id) and normalize_person_id(waiver.get("A_Number")) == user_id) or (bool(user_email) and normalize_email(waiver.get("Email")) == user_email) for waiver in waivers) timings["waiver_lookup"] = elapsed_ms(stage_started) if not waiver_found: timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="waiver_required", - message="A current waiver is required before check-in.", - timings_ms=timings, - ) - + return CheckInResult(outcome="waiver_required", message="A current waiver is required before check-in.", timings_ms=timings) display_name = str(user.get("Name", "")).strip() or "Sandbox member" + person_id = str(user.get("Person ID", "")).strip() local_now = self.local_datetime() - today = local_now.strftime("%m/%d/%Y") + today = local_now.date().isoformat() stage_started = time.monotonic() activity_rows = self.provider.activity_rows() visit_dates = { - str(row[0]).split()[0] + str(row[2]).split("T")[0].split()[0] for row in activity_rows[1:] - if len(row) >= 5 - and str(row[3]).strip().upper() == activity_identifier - and str(row[4]).strip() == "User Checkin" - and str(row[0]).strip() + if len(row) >= 4 and str(row[1]).strip() == person_id and str(row[3]).strip() == "User Checkin" and str(row[2]).strip() } timings["activity_lookup"] = elapsed_ms(stage_started) visit_count = len(visit_dates | {today}) row = [ - local_now.strftime("%m/%d/%Y %H:%M:%S"), - int(self.now()), - display_name, - activity_identifier, + "visit_" + uuid4().hex, + person_id, + local_now.isoformat(timespec="seconds"), "User Checkin", "", "", "", + "Kiosk v2", + "", ] stage_started = time.monotonic() self.provider.append_activity(row) timings["activity_append"] = elapsed_ms(stage_started) timings["total"] = elapsed_ms(total_started) - return CheckInResult( - outcome="success", - display_name=display_name, - message="Check-in recorded.", - visit_count=visit_count, - timings_ms=timings, - ) + return CheckInResult(outcome="success", display_name=display_name, message="Check-in recorded.", visit_count=visit_count, timings_ms=timings) diff --git a/Kiosk-v2/bridge/tests/test_sheets_backend.py b/Kiosk-v2/bridge/tests/test_sheets_backend.py index 9bed2cb..b12f7f3 100644 --- a/Kiosk-v2/bridge/tests/test_sheets_backend.py +++ b/Kiosk-v2/bridge/tests/test_sheets_backend.py @@ -1,53 +1,53 @@ from datetime import datetime -from sheets_backend import ( - GoogleSheetsProvider, - SheetsCheckInBackend, - normalize_person_id, -) +from sheets_backend import GoogleSheetsProvider, SheetsCheckInBackend, normalize_person_id class FakeProvider: def __init__(self, users=None, waivers=None, existing_activity=None): - self.users = users or [] - self.waivers = waivers or [] - self.existing_activity = existing_activity or [ - ["Timestamp", "Epoch", "Name", "Card UUID", "Action"] - ] + self.users = [dict(row) for row in (users or [])] + self.waivers = [dict(row) for row in (waivers or [])] + self.existing_activity = existing_activity or [[ + "Visit ID", "Person ID", "Check In At", "Event Type", + "Authorizing Entity", "Flags", "Notes", "Source System", "Source Row", + ]] self.appended_rows = [] self.calls = {"users": 0, "waivers": 0, "activity": 0, "append": 0, "card_update": 0} + @staticmethod + def card_digest(card_uid): + return "digest:" + str(card_uid).strip().lower() + def user_records(self): self.calls["users"] += 1 - return self.users + return [dict(row) for row in self.users] def waiver_records(self): self.calls["waivers"] += 1 - return self.waivers + return [dict(row) for row in self.waivers] def activity_rows(self): self.calls["activity"] += 1 - return self.existing_activity + return [list(row) for row in self.existing_activity] def append_activity(self, row): self.calls["append"] += 1 - self.appended_rows.append(row) + self.appended_rows.append(list(row)) + self.existing_activity.append(list(row)) def update_user_card(self, identifier, card_uid): self.calls["card_update"] += 1 - matches = [ - record - for record in self.users - if normalize_person_id(record.get("Student ID")) == normalize_person_id(identifier) - ] + normalized = normalize_person_id(identifier) + matches = [row for row in self.users if normalize_person_id(row.get("Student ID")) == normalized] if len(matches) != 1: raise ValueError("The account could not be identified uniquely.") - if matches[0].get("Card UUID"): - raise ValueError("That account already has a connected card.") - if any(record.get("Card UUID") == card_uid for record in self.users): + digest = self.card_digest(card_uid) + if any(str(row.get("Card Digest", "")).lower() == digest for row in self.users): raise ValueError("That card is already connected to an account.") - matches[0]["Card UUID"] = card_uid - return matches[0] + if matches[0].get("Card Digest"): + raise ValueError("That account already has a connected card.") + matches[0]["Card Digest"] = digest + return dict(matches[0]) def backend_for(provider): @@ -58,284 +58,181 @@ def backend_for(provider): ) -class FakeActivitySheet: +def member(card="CARD123", person_id="person_1", student_id="A12345678", email="maker@example.com"): + return { + "Person ID": person_id, + "Name": "Test Maker", + "Student ID": student_id, + "Email Address": email, + "Card Digest": FakeProvider.card_digest(card) if card else "", + } + + +def signed_waiver(student_id="A12345678", email="maker@example.com"): + return {"A_Number": student_id, "Email": email} + + +class FakeVisitsSheet: def __init__(self): - self.rows = [["Timestamp", "Epoch", "Name", "Card UUID", "Action"]] + self.rows = [["Visit ID", "Person ID", "Check In At", "Event Type"]] self.reads = 0 - self.appends = 0 + self.appends = [] def get_all_values(self): self.reads += 1 return [list(row) for row in self.rows] - def append_row(self, row): - self.appends += 1 + def append_row(self, row, value_input_option=None): + self.appends.append((list(row), value_input_option)) self.rows.append(list(row)) -class FakePeopleSheet: - def __init__(self, records): - self.records = records - self.updated_cells = [] - - def get_all_records(self, numericise_ignore=None): - return self.records - - def row_values(self, row): - assert row == 1 - return ["Name", "Student ID", "Card UUID"] +class FakeCardsSheet: + def __init__(self): + self.appends = [] - def update_cell(self, row, column, value): - self.updated_cells.append((row, column, value)) + def append_row(self, row, value_input_option=None): + self.appends.append((list(row), value_input_option)) def test_activity_cache_avoids_full_sheet_read_after_append(): - sheet = FakeActivitySheet() - provider = GoogleSheetsProvider("credentials", "users", "waivers", "activity") - provider._activity_sheet = sheet - + sheet = FakeVisitsSheet() + provider = GoogleSheetsProvider("credentials", "database", "waivers", "long-enough-test-secret") + provider._visits_sheet = sheet assert len(provider.activity_rows()) == 1 - provider.append_activity(["08/11/2024 09:30:00", "", "Maker", "CARD", "User Checkin"]) + provider.append_activity(["visit_1", "person_1", "2024-08-11T09:30:00", "User Checkin", "", "", "", "Kiosk v2", ""]) assert len(provider.activity_rows()) == 2 assert sheet.reads == 1 - assert sheet.appends == 1 + assert len(sheet.appends) == 1 -def test_google_sheets_provider_updates_only_the_target_card_cell(): - user_sheet = FakePeopleSheet([ - {"Name": "First Member", "Student ID": "A11111111", "Card UUID": ""}, - {"Name": "Target Member", "Student ID": "A12345678", "Card UUID": ""}, - ]) - waiver_sheet = FakePeopleSheet([]) - provider = GoogleSheetsProvider("credentials", "users", "waivers", "activity") - provider._user_sheet = user_sheet - provider._waiver_sheet = waiver_sheet - provider._activity_sheet = FakeActivitySheet() - - updated = provider.update_user_card("12345678", "newcard123456") - - assert user_sheet.updated_cells == [(3, 3, "NEWCARD123456")] - assert updated["Card UUID"] == "NEWCARD123456" +def test_google_sheets_provider_appends_only_a_digest_and_last_four_for_new_card(): + provider = GoogleSheetsProvider("credentials", "database", "waivers", "long-enough-test-secret") + provider._visits_sheet = FakeVisitsSheet() + provider._cards_sheet = FakeCardsSheet() + provider._users = [member(card="", person_id="person_2")] + provider._cache_expires_at = float("inf") + updated = provider.update_user_card("12345678", "ABCDEF12345678") + row, option = provider._cards_sheet.appends[0] + assert updated["Card Digest"] == provider.card_digest("ABCDEF12345678") + assert row[1] == "person_2" + assert row[2] == provider.card_digest("ABCDEF12345678") + assert row[3] == "5678" + assert "ABCDEF12345678" not in row + assert option == "USER_ENTERED" def test_warm_up_loads_all_read_heavy_sources(): provider = FakeProvider() - timings = backend_for(provider).warm_up() - assert provider.calls == {"users": 1, "waivers": 1, "activity": 1, "append": 0, "card_update": 0} assert set(timings) == {"users", "waivers", "activity", "total"} -def test_known_card_with_waiver_appends_existing_activity_shape(): - provider = FakeProvider( - users=[ - { - "Card UUID": "ABCDEF12345678", - "Student ID": "A12345678", - "Email Address": "maker@ucsd.edu", - "Name": "Test Maker", - } - ], - waivers=[{"A_Number": "12345678", "Email": ""}], - ) - - result = backend_for(provider).check_in("abcdef12345678") - +def test_known_card_with_waiver_appends_normalized_visit_shape(): + provider = FakeProvider(users=[member()], waivers=[signed_waiver()]) + result = backend_for(provider).check_in("CARD123") assert result.outcome == "success" assert result.display_name == "Test Maker" assert result.visit_count == 1 - assert set(result.timings_ms) == { - "user_lookup", - "waiver_lookup", - "activity_lookup", - "activity_append", - "total", - } - assert provider.appended_rows == [ - [ - "08/11/2024 09:30:00", - 1_723_377_600, - "Test Maker", - "ABCDEF12345678", - "User Checkin", - "", - "", - "", - ] - ] + assert provider.calls["append"] == 1 + row = provider.appended_rows[0] + assert len(row) == 9 + assert row[0].startswith("visit_") + assert row[1] == "person_1" + assert row[2] == "2024-08-11T09:30:00" + assert row[3] == "User Checkin" + assert row[7] == "Kiosk v2" + assert "CARD123" not in row def test_unknown_card_does_not_read_waivers_or_activity_or_write(): provider = FakeProvider() - - result = backend_for(provider).check_in("ABCDEF12345678") - + result = backend_for(provider).check_in("UNKNOWN") assert result.outcome == "unknown_card" assert provider.calls == {"users": 1, "waivers": 0, "activity": 0, "append": 0, "card_update": 0} - assert provider.appended_rows == [] def test_known_card_without_waiver_does_not_read_activity_or_write(): - provider = FakeProvider( - users=[ - { - "Card UUID": "ABCDEF12345678", - "Student ID": "A12345678", - "Email Address": "maker@ucsd.edu", - "Name": "Test Maker", - } - ], - waivers=[], - ) - - result = backend_for(provider).check_in("ABCDEF12345678") - + provider = FakeProvider(users=[member()], waivers=[]) + result = backend_for(provider).check_in("CARD123") assert result.outcome == "waiver_required" - assert provider.calls == {"users": 1, "waivers": 1, "activity": 0, "append": 0, "card_update": 0} - assert provider.appended_rows == [] + assert provider.calls["activity"] == 0 + assert provider.calls["append"] == 0 -def test_email_match_is_case_insensitive(): +def test_email_waiver_match_is_case_insensitive(): provider = FakeProvider( - users=[ - { - "Card UUID": "ABCDEF12345678", - "Student ID": "", - "Email Address": "Maker@UCSD.edu ", - "Name": "Email Maker", - } - ], - waivers=[{"A_Number": "", "Email": "maker@ucsd.EDU"}], + users=[member(student_id="", email="Maker@Example.com")], + waivers=[signed_waiver(student_id="", email="maker@example.COM")], ) + assert backend_for(provider).check_in("CARD123").outcome == "success" - result = backend_for(provider).check_in("ABCDEF12345678") - - assert result.outcome == "success" - assert len(provider.appended_rows) == 1 - -def test_person_id_normalization_preserves_legacy_sheet_behavior(): +def test_person_id_normalization_preserves_leading_a_behavior(): assert normalize_person_id(" A12345678 ") == "12345678" assert normalize_person_id("12345678") == "12345678" - assert normalize_person_id("") == "" def test_visit_count_uses_unique_calendar_days_while_recording_each_checkin(): - provider = FakeProvider( - users=[ - { - "Card UUID": "ABCDEF12345678", - "Student ID": "A12345678", - "Email Address": "maker@ucsd.edu", - "Name": "Test Maker", - } - ], - waivers=[{"A_Number": "12345678", "Email": ""}], - existing_activity=[ - ["Timestamp", "Epoch", "Name", "Card UUID", "Action"], - ["08/10/2024 09:00:00", "", "Test Maker", "ABCDEF12345678", "User Checkin"], - ["08/11/2024 08:00:00", "", "Test Maker", "ABCDEF12345678", "User Checkin"], - ["08/11/2024 08:01:00", "", "Test Maker", "ABCDEF12345678", "User Checkin"], - ["08/09/2024 08:00:00", "", "Someone Else", "1111222233334444", "User Checkin"], - ], - ) - - result = backend_for(provider).check_in("ABCDEF12345678") - + rows = [ + ["Visit ID", "Person ID", "Check In At", "Event Type"], + ["visit_old_1", "person_1", "2024-08-10T10:00:00", "User Checkin"], + ["visit_old_2", "person_1", "2024-08-10T12:00:00", "User Checkin"], + ["visit_other", "person_2", "2024-08-09T10:00:00", "User Checkin"], + ] + provider = FakeProvider(users=[member()], waivers=[signed_waiver()], existing_activity=rows) + result = backend_for(provider).check_in("CARD123") assert result.outcome == "success" assert result.visit_count == 2 assert len(provider.appended_rows) == 1 -def test_identifier_checkin_uses_the_existing_card_activity_key(): - provider = FakeProvider( - users=[ - { - "Card UUID": "ABCDEF12345678", - "Student ID": "A12345678", - "Email Address": "maker@ucsd.edu", - "Name": "Test Maker", - } - ], - waivers=[{"A_Number": "12345678", "Email": ""}], - ) - - result = backend_for(provider).check_in_identifier("a12345678") +def test_identifier_checkin_uses_person_id_without_requiring_a_card(): + provider = FakeProvider(users=[member(card="")], waivers=[signed_waiver()]) + result = backend_for(provider).check_in_identifier("A12345678") assert result.outcome == "success" - assert result.display_name == "Test Maker" - assert provider.appended_rows[0][3] == "ABCDEF12345678" - - -def test_identifier_checkin_supports_accounts_without_cards(): - provider = FakeProvider( - users=[ - { - "Card UUID": "", - "Student ID": "A12345678", - "Email Address": "maker@ucsd.edu", - "Name": "Test Maker", - } - ], - waivers=[{"A_Number": "12345678", "Email": ""}], - ) - - result = backend_for(provider).check_in_identifier("12345678") - - assert result.outcome == "success" - assert provider.appended_rows[0][3] == "12345678" + assert provider.appended_rows[0][1] == "person_1" + assert all("CARD123" not in str(value) for value in provider.appended_rows[0]) def test_unknown_identifier_does_not_read_waivers_or_write(): - provider = FakeProvider() - + provider = FakeProvider(users=[member()]) result = backend_for(provider).check_in_identifier("A99999999") - assert result.outcome == "unknown_identifier" - assert provider.calls == {"users": 1, "waivers": 0, "activity": 0, "append": 0, "card_update": 0} - assert provider.appended_rows == [] + assert provider.calls["waivers"] == 0 + assert provider.calls["append"] == 0 def test_staff_assisted_card_link_requires_a_designated_staff_card(): - provider = FakeProvider(users=[ - { - "Card UUID": "", - "Student ID": "A12345678", - "Email Address": "member@ucsd.edu", - "Name": "Test Member", - }, - { - "Card UUID": "STAFF12345678", - "Student ID": "A87654321", - "Email Address": "staff@ucsd.edu", - "Name": "Test Staff", - }, - ]) + target = member(card="", person_id="person_member", student_id="A12345678") + staff = member(card="STAFFCARD", person_id="person_staff", student_id="A87654321") + provider = FakeProvider(users=[target, staff]) backend = backend_for(provider) - - denied = backend.link_card( - "A12345678", "NEWCARD123456", "STAFF12345678", {"A11111111"} - ) - assert denied.outcome == "staff_unauthorized" - assert provider.users[0]["Card UUID"] == "" - - linked = backend.link_card( - "A12345678", "NEWCARD123456", "STAFF12345678", {"A87654321"} - ) - assert linked.outcome == "card_linked" - assert linked.display_name == "Test Member" - assert provider.users[0]["Card UUID"] == "NEWCARD123456" - assert provider.appended_rows[-1][4] == "Card Linked" - assert provider.appended_rows[-1][5] == "Test Staff" - - -def test_card_link_target_must_exist_and_have_no_existing_card(): - provider = FakeProvider(users=[{ - "Card UUID": "EXISTING1234", - "Student ID": "A12345678", - "Name": "Existing Member", - }]) - backend = backend_for(provider) - - assert backend.prepare_card_link("A99999999").outcome == "unknown_identifier" - assert backend.prepare_card_link("A12345678").outcome == "card_link_error" + rejected = backend.link_card("A12345678", "NEWCARD", "NOTSTAFF", {"A87654321"}) + assert rejected.outcome == "staff_unauthorized" + assert provider.calls["card_update"] == 0 + accepted = backend.link_card("A12345678", "NEWCARD", "STAFFCARD", {"A87654321"}) + assert accepted.outcome == "card_linked" + assert provider.calls["card_update"] == 1 + assert provider.appended_rows[0][3] == "Card Linked" + assert all("NEWCARD" not in str(value) for value in provider.appended_rows[0]) + + +def test_card_link_target_must_have_no_existing_card(): + target = member(card="OLDCARD", person_id="person_member", student_id="A12345678") + staff = member(card="STAFFCARD", person_id="person_staff", student_id="A87654321") + provider = FakeProvider(users=[target, staff]) + result = backend_for(provider).link_card("A12345678", "NEWCARD", "STAFFCARD", {"A87654321"}) + assert result.outcome == "card_link_error" + assert provider.calls["append"] == 0 + + +def test_duplicate_member_card_is_rejected(): + target = member(card="", person_id="person_member", student_id="A12345678") + other = member(card="NEWCARD", person_id="person_other", student_id="A11111111") + staff = member(card="STAFFCARD", person_id="person_staff", student_id="A87654321") + provider = FakeProvider(users=[target, other, staff]) + result = backend_for(provider).link_card("A12345678", "NEWCARD", "STAFFCARD", {"A87654321"}) + assert result.outcome == "card_link_error" + assert provider.calls["append"] == 0 From ec43ef2023b5a3a611782c0c09a24049244f996e Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Thu, 13 Aug 2026 11:39:41 -0700 Subject: [PATCH 02/26] Support Triton Student Numbers --- Kiosk-v2/app/api/registrations/route.ts | 10 +++++----- Kiosk-v2/app/join/page.tsx | 18 ++++++++++++++++-- Kiosk-v2/app/page.tsx | 18 +++++++++--------- Kiosk-v2/apps-script-registration/Index.html | 19 +++++++++++++++++-- Kiosk-v2/apps-script-registration/README.md | 3 ++- .../RegistrationCore.gs | 6 +++++- .../test/registration-core.test.cjs | 6 ++++++ .../test/submission.test.cjs | 12 ++++++++++++ Kiosk-v2/bridge/README.md | 4 ++-- Kiosk-v2/bridge/app.py | 4 ++-- Kiosk-v2/bridge/sheets_backend.py | 4 ++-- Kiosk-v2/lib/registration.ts | 18 ++++++++++++++++++ Kiosk-v2/tests/registration.test.ts | 17 +++++++++++++++++ 13 files changed, 113 insertions(+), 26 deletions(-) create mode 100644 Kiosk-v2/tests/registration.test.ts diff --git a/Kiosk-v2/app/api/registrations/route.ts b/Kiosk-v2/app/api/registrations/route.ts index 4173ab8..f052281 100644 --- a/Kiosk-v2/app/api/registrations/route.ts +++ b/Kiosk-v2/app/api/registrations/route.ts @@ -12,7 +12,7 @@ import { id, makeDisplayName, normalizeEmail, - normalizeIdentifier, + normalizeRegistrationIdentifier, REGISTRATION_CONSENT_VERSION, WAIVER_POWERFORM_URL, } from "@/lib/registration"; @@ -31,7 +31,7 @@ type RegistrationPayload = { }; const allowedUserTypes = new Set(["student", "staff", "faculty", "postdoc", "visitor", "other"]); -const allowedIdentifierTypes = new Set(["pid", "employee_id", "other"]); +const allowedIdentifierTypes = new Set(["pid", "tsn", "employee_id", "other"]); function validEmail(value: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); @@ -66,7 +66,7 @@ export async function POST(request: Request) { : null; const identifierType = payload.identifierType?.trim().toLowerCase() ?? ""; const identifierValue = payload.identifierValue?.trim() ?? ""; - const normalizedIdentifier = normalizeIdentifier(identifierValue); + const normalizedIdentifier = normalizeRegistrationIdentifier(identifierValue, identifierType); if (!firstName || !lastName || !affiliation) { return Response.json({ error: "Name and affiliation are required." }, { status: 400 }); @@ -77,8 +77,8 @@ export async function POST(request: Request) { if (!validEmail(primaryEmail) || (secondaryEmail && !validEmail(secondaryEmail))) { return Response.json({ error: "Enter a valid email address." }, { status: 400 }); } - if (normalizedIdentifier.length < 4 || normalizedIdentifier.length > 32) { - return Response.json({ error: "Enter a valid UC San Diego ID number." }, { status: 400 }); + if (!normalizedIdentifier) { + return Response.json({ error: "Enter a valid PID, TSN, or employee ID." }, { status: 400 }); } if (!payload.consent) { return Response.json({ error: "Consent is required to create an account." }, { status: 400 }); diff --git a/Kiosk-v2/app/join/page.tsx b/Kiosk-v2/app/join/page.tsx index b1903da..6ce25e1 100644 --- a/Kiosk-v2/app/join/page.tsx +++ b/Kiosk-v2/app/join/page.tsx @@ -24,6 +24,7 @@ export default function JoinPage() { const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [copied, setCopied] = useState(false); + const [identifierType, setIdentifierType] = useState("pid"); async function submit(event: FormEvent) { event.preventDefault(); @@ -108,13 +109,26 @@ export default function JoinPage() {
- +
diff --git a/Kiosk-v2/app/page.tsx b/Kiosk-v2/app/page.tsx index 5f8e5ba..e3a8b45 100644 --- a/Kiosk-v2/app/page.tsx +++ b/Kiosk-v2/app/page.tsx @@ -456,7 +456,7 @@ export default function Home() {

Tap your
UC San Diego ID.

Hold your card near the reader to check in.

{scannerStatus === "disconnected" && ( -

Card reader unavailable. Use your PID or employee ID below.

+

Card reader unavailable. Use your PID, TSN, or employee ID below.

)} {(announcement.active || minutesUntilClose !== null) && (
@@ -482,7 +482,7 @@ export default function Home() { )}

CHECK IN WITHOUT A CARD

Enter your ID.

-

Use your PID or UC San Diego employee ID.

+

Use your student PID, nine-digit TSN, or UC San Diego employee ID.

- + Already registered? Designated staff can connect this card after checking your physical ID.

- +
@@ -594,9 +594,9 @@ export default function Home() {

STAFF-ASSISTED CARD LINK

Confirm the
member’s account.

-

Staff: inspect the member’s physical ID, then enter its PID or employee ID.

+

Staff: inspect the member’s physical ID, then enter their PID, TSN, or employee ID.

- + Hold it flat against the reader for a full second.

- +
)} @@ -764,7 +764,7 @@ export default function Home() { - + diff --git a/Kiosk-v2/apps-script-registration/Index.html b/Kiosk-v2/apps-script-registration/Index.html index 964fe6d..51de033 100644 --- a/Kiosk-v2/apps-script-registration/Index.html +++ b/Kiosk-v2/apps-script-registration/Index.html @@ -103,8 +103,8 @@

Create your Sandbox account.

- - + +
@@ -134,8 +134,23 @@

Next: complete the liability waiver.

const form = document.getElementById("registration"); const affiliation = document.getElementById("affiliation"); const otherWrap = document.getElementById("other-wrap"); + const identifierType = document.getElementById("identifier-type"); + const identifier = document.getElementById("identifier"); const startedAt = Date.now(); + identifierType.addEventListener("change", () => { + if (identifierType.value === "Triton Student Number (TSN)") { + identifier.placeholder = "200010746"; + identifier.inputMode = "numeric"; + } else if (identifierType.value === "Employee ID") { + identifier.placeholder = "000023"; + identifier.inputMode = "numeric"; + } else { + identifier.placeholder = "A12345678"; + identifier.inputMode = "text"; + } + }); + affiliation.addEventListener("change", () => { const other = affiliation.value === "Other"; otherWrap.hidden = !other; diff --git a/Kiosk-v2/apps-script-registration/README.md b/Kiosk-v2/apps-script-registration/README.md index 948e7e6..d2bb7c7 100644 --- a/Kiosk-v2/apps-script-registration/README.md +++ b/Kiosk-v2/apps-script-registration/README.md @@ -10,7 +10,8 @@ The kiosk still requires a matching waiver record before check-in succeeds. Afte - Give the script only the explicit Google Sheets OAuth scope in `appsscript.json`. - Keep the user database private to designated Sandbox staff. - The public app accepts writes only. It never returns database rows. -- An anonymous submission cannot update an existing PID or email. +- An anonymous submission cannot update an existing PID, TSN, employee ID, or email. +- Student identifiers support legacy PIDs and nine-digit Triton Student Numbers (TSNs). They remain separate identifier types so a future authorized PID-to-TSN crosswalk can attach both values to one person without discarding the PID. - Input is length-limited, normalized, and protected against spreadsheet-formula injection. - A script lock serializes duplicate checks and appends. - The form includes a honeypot and minimum-fill-time check. diff --git a/Kiosk-v2/apps-script-registration/RegistrationCore.gs b/Kiosk-v2/apps-script-registration/RegistrationCore.gs index 2d85202..f65b09e 100644 --- a/Kiosk-v2/apps-script-registration/RegistrationCore.gs +++ b/Kiosk-v2/apps-script-registration/RegistrationCore.gs @@ -10,6 +10,7 @@ const REGISTRATION_ALLOWED_ROLES_ = [ const REGISTRATION_ALLOWED_ID_TYPES_ = [ "Student PID", + "Triton Student Number (TSN)", "Employee ID", "Other UC San Diego ID", ]; @@ -70,6 +71,9 @@ function normalizeIdentifier_(value, identifierType) { if (!/^A?\d{8}$/.test(normalized)) return ""; return normalized.charAt(0) === "A" ? normalized : "A" + normalized; } + if (identifierType === "Triton Student Number (TSN)") { + return /^\d{9}$/.test(normalized) ? normalized : ""; + } if (identifierType === "Employee ID") { return /^\d{6,12}$/.test(normalized) ? normalized : ""; } @@ -110,7 +114,7 @@ function validateRegistration_(payload, nowMs) { if (REGISTRATION_ALLOWED_ROLES_.indexOf(role) === -1) return { ok: false, message: "Choose your role." }; if (!affiliation) return { ok: false, message: "Choose your program, department, or organization." }; if (REGISTRATION_ALLOWED_ID_TYPES_.indexOf(identifierType) === -1 || !identifier) { - return { ok: false, message: "Enter a valid PID or employee ID." }; + return { ok: false, message: "Enter a valid PID, TSN, or employee ID." }; } if (!isValidEmail_(primaryEmail) || (secondaryEmail && !isValidEmail_(secondaryEmail))) { return { ok: false, message: "Enter a valid email address." }; diff --git a/Kiosk-v2/apps-script-registration/test/registration-core.test.cjs b/Kiosk-v2/apps-script-registration/test/registration-core.test.cjs index 6a919a2..0609096 100644 --- a/Kiosk-v2/apps-script-registration/test/registration-core.test.cjs +++ b/Kiosk-v2/apps-script-registration/test/registration-core.test.cjs @@ -20,6 +20,12 @@ test("accepts employee IDs without pretending they are student PIDs", () => { assert.equal(call('normalizeIdentifier_("A12345678", "Employee ID")'), ""); }); +test("accepts exactly nine digits for Triton Student Numbers", () => { + assert.equal(call('normalizeIdentifier_("200-010-746", "Triton Student Number (TSN)")'), "200010746"); + assert.equal(call('normalizeIdentifier_("20001074", "Triton Student Number (TSN)")'), ""); + assert.equal(call('normalizeIdentifier_("A00010746", "Triton Student Number (TSN)")'), ""); +}); + test("requires canonical affiliation choices and an explanation for Other", () => { assert.equal(call('canonicalAffiliation_("Scripps – Biological Oceanography", "")'), "Scripps – Biological Oceanography"); assert.equal(call('canonicalAffiliation_("Other", "Coastal nonprofit")'), "Other – Coastal nonprofit"); diff --git a/Kiosk-v2/apps-script-registration/test/submission.test.cjs b/Kiosk-v2/apps-script-registration/test/submission.test.cjs index d8a0a8e..d0e1b1c 100644 --- a/Kiosk-v2/apps-script-registration/test/submission.test.cjs +++ b/Kiosk-v2/apps-script-registration/test/submission.test.cjs @@ -99,6 +99,18 @@ test("appends an immediately active row with after-the-fact review metadata", () assert.equal(harness.wasReleased(), true); }); +test("appends a TSN as the student's primary identifier", () => { + const harness = makeHarness(); + harness.context.payload = validPayload({ + identifierType: "Triton Student Number (TSN)", + identifier: "200010746", + }); + const result = vm.runInContext("submitRegistration(payload)", harness.context); + assert.equal(result.ok, true); + assert.equal(harness.appendedUsers[0][userHeaders.indexOf("Student ID")], "200010746"); + assert.equal(harness.appendedReviews[0][reviewHeaders.indexOf("Identifier Type")], "Triton Student Number (TSN)"); +}); + test("does not overwrite or append when an ID already exists", () => { const harness = makeHarness([["A12345678", "Graduate student", "someone@example.edu"]]); harness.context.payload = validPayload(); diff --git a/Kiosk-v2/bridge/README.md b/Kiosk-v2/bridge/README.md index 2c580ca..b78c3cc 100644 --- a/Kiosk-v2/bridge/README.md +++ b/Kiosk-v2/bridge/README.md @@ -16,7 +16,7 @@ The Sheets backend warms its user, waiver, and activity caches at startup. The u ## Designated-staff card linking -When an unrecognized member card is scanned, the bridge keeps its UID in memory for five minutes. A staff member verifies the member's physical ID, enters the matching PID or employee ID in the kiosk, and approves the link by tapping their own already-linked card. The member's UID is never sent to the browser. Successful links update `Card UUID` in the user database and append a `Card Linked` audit row to the activity sheet. +When an unrecognized member card is scanned, the bridge keeps its UID in memory for five minutes. A staff member verifies the member's physical ID, enters the matching PID, TSN, or employee ID in the kiosk, and approves the link by tapping their own already-linked card. The member's UID is never sent to the browser. Successful links update `Card UUID` in the user database and append a `Card Linked` audit row to the activity sheet. Add the identifiers of the staff allowed to approve links to `/etc/sandbox-kiosk/scanner.env`: @@ -28,7 +28,7 @@ CARD_LINK_STAFF_IDS=A12345678,123456789 CARD_LINK_SESSION_SECONDS=300 ``` -`CARD_LINK_STAFF_IDS` contains PIDs or employee IDs, not card UIDs. Each designated staff account must already have a card in the user database. Restart the bridge after changing the allowlist. If the member already has a card, resolve the replacement manually rather than overwriting it at the kiosk. +`CARD_LINK_STAFF_IDS` contains PIDs, TSNs, or employee IDs, not card UIDs. Each designated staff account must already have a card in the user database. Restart the bridge after changing the allowlist. If the member already has a card, resolve the replacement manually rather than overwriting it at the kiosk. ## Test without hardware diff --git a/Kiosk-v2/bridge/app.py b/Kiosk-v2/bridge/app.py index 76b1922..33bd57b 100644 --- a/Kiosk-v2/bridge/app.py +++ b/Kiosk-v2/bridge/app.py @@ -334,7 +334,7 @@ class CardLinkStart(BaseModel): async def start_card_link(request: CardLinkStart) -> dict[str, Any]: identifier = request.identifier.strip() if not identifier or len(identifier) > 64: - raise HTTPException(status_code=422, detail="Enter a valid PID or employee ID") + raise HTTPException(status_code=422, detail="Enter a valid PID, TSN, or employee ID") if STATE.backend is None: raise HTTPException(status_code=409, detail="Card linking is unavailable in demo mode") if not STATE.designated_card_link_staff_ids: @@ -375,7 +375,7 @@ async def cancel_card_link() -> dict[str, bool]: async def check_in_with_identifier(read: IdentifierCheckIn) -> dict[str, Any]: identifier = read.identifier.strip() if not identifier or len(identifier) > 64: - raise HTTPException(status_code=422, detail="Enter a valid PID or employee ID") + raise HTTPException(status_code=422, detail="Enter a valid PID, TSN, or employee ID") read_at = datetime.now(timezone.utc).isoformat() started_at = asyncio.get_running_loop().time() diff --git a/Kiosk-v2/bridge/sheets_backend.py b/Kiosk-v2/bridge/sheets_backend.py index 36c2366..c9da322 100644 --- a/Kiosk-v2/bridge/sheets_backend.py +++ b/Kiosk-v2/bridge/sheets_backend.py @@ -274,7 +274,7 @@ def check_in_identifier(self, identifier: str) -> CheckInResult: timings["user_lookup"] = elapsed_ms(stage_started) if not matches: timings["total"] = elapsed_ms(total_started) - return CheckInResult(outcome="unknown_identifier", message="We could not find that PID or employee ID.", timings_ms=timings) + return CheckInResult(outcome="unknown_identifier", message="We could not find that PID, TSN, or employee ID.", timings_ms=timings) if len(matches) > 1: timings["total"] = elapsed_ms(total_started) return CheckInResult(outcome="backend_error", message="More than one account uses that identifier. Please see staff.", timings_ms=timings) @@ -284,7 +284,7 @@ def prepare_card_link(self, identifier: str) -> CheckInResult: normalized_identifier = normalize_person_id(identifier) matches = [record for record in self.provider.user_records() if normalized_identifier and normalize_person_id(record.get("Student ID")) == normalized_identifier] if not matches: - return CheckInResult(outcome="unknown_identifier", message="We could not find that PID or employee ID.") + return CheckInResult(outcome="unknown_identifier", message="We could not find that PID, TSN, or employee ID.") if len(matches) > 1: return CheckInResult(outcome="card_link_error", message="More than one account uses that identifier. Please see an administrator.") target = matches[0] diff --git a/Kiosk-v2/lib/registration.ts b/Kiosk-v2/lib/registration.ts index b9f2216..fd410a1 100644 --- a/Kiosk-v2/lib/registration.ts +++ b/Kiosk-v2/lib/registration.ts @@ -12,6 +12,24 @@ export function normalizeIdentifier(value: string) { return value.trim().toUpperCase().replace(/[\s-]+/g, ""); } +export function normalizeRegistrationIdentifier(value: string, identifierType: string) { + const normalized = normalizeIdentifier(value); + if (identifierType === "pid") { + if (!/^A?\d{8}$/.test(normalized)) return ""; + return normalized.startsWith("A") ? normalized : `A${normalized}`; + } + if (identifierType === "tsn") { + return /^\d{9}$/.test(normalized) ? normalized : ""; + } + if (identifierType === "employee_id") { + return /^\d{6,12}$/.test(normalized) ? normalized : ""; + } + if (identifierType === "other") { + return /^[A-Z0-9]{4,20}$/.test(normalized) ? normalized : ""; + } + return ""; +} + export function makeDisplayName(firstName: string, lastName: string, preferredName?: string) { return `${preferredName?.trim() || firstName.trim()} ${lastName.trim()}`.trim(); } diff --git a/Kiosk-v2/tests/registration.test.ts b/Kiosk-v2/tests/registration.test.ts new file mode 100644 index 0000000..34e1998 --- /dev/null +++ b/Kiosk-v2/tests/registration.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeRegistrationIdentifier } from "../lib/registration.ts"; + +test("normalizes registration identifiers according to their declared type", () => { + assert.equal(normalizeRegistrationIdentifier("1234-5678", "pid"), "A12345678"); + assert.equal(normalizeRegistrationIdentifier("200-010-746", "tsn"), "200010746"); + assert.equal(normalizeRegistrationIdentifier("000023", "employee_id"), "000023"); +}); + +test("does not accept one identifier format as another type", () => { + assert.equal(normalizeRegistrationIdentifier("A12345678", "tsn"), ""); + assert.equal(normalizeRegistrationIdentifier("200010746", "pid"), ""); + assert.equal(normalizeRegistrationIdentifier("20001074", "tsn"), ""); + assert.equal(normalizeRegistrationIdentifier("2000107460", "tsn"), ""); +}); From 8351163ef212d90f4bea5a7d2c37511d7e4879ac Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Thu, 13 Aug 2026 11:49:48 -0700 Subject: [PATCH 03/26] Preserve deployed registration kiosk behavior --- Kiosk-v2/apps-script-registration/Index.html | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Kiosk-v2/apps-script-registration/Index.html b/Kiosk-v2/apps-script-registration/Index.html index 51de033..4e085b8 100644 --- a/Kiosk-v2/apps-script-registration/Index.html +++ b/Kiosk-v2/apps-script-registration/Index.html @@ -36,6 +36,8 @@ .complete { grid-column:1 / -1; width:min(720px,100%); margin:auto; } .complete p { font-size:19px; } .waiver { margin-top:28px; } + .kiosk-waiver { text-align:center; padding:22px; border:2px solid var(--navy); margin-top:20px; } + .kiosk-waiver img { display:block; width:280px; max-width:100%; margin:16px auto; background:white; padding:12px; } [hidden] { display:none !important; } @media (max-width:760px) { main { grid-template-columns:1fr; } .grid { grid-template-columns:1fr; gap:0; } h1 { font-size:48px; } } @@ -126,11 +128,13 @@

Create your Sandbox account.

Next: complete the liability waiver.

Your Sandbox account is available immediately. Check-in will begin working after your signed waiver appears in the Sandbox waiver records and authorized staff link your card.

Open the DocuSign waiver +

Sandbox staff may review this registration afterward and contact you if anything needs clarification.

+ + + diff --git a/Kiosk-v2/apps-script-staff/README.md b/Kiosk-v2/apps-script-staff/README.md new file mode 100644 index 0000000..807844f --- /dev/null +++ b/Kiosk-v2/apps-script-staff/README.md @@ -0,0 +1,16 @@ +# Staff desk Apps Script + +Responsive staff-only web app backed by the same normalized Google spreadsheet as the kiosk. + +## Deploy + +1. Create a standalone Apps Script project owned by the UCSD Sandbox account. +2. Copy `appsscript.json`, `StaffCore.gs`, `Code.gs`, and `Index.html` into it. +3. Set script property `USER_DATABASE_SPREADSHEET_ID` to the normalized database ID. +4. In the `Staff Access` tab, make the deploying account active with role `administrator`. +5. Run `setupStaffApp()` once to create `Tool Training` and `Staff Notes`. +6. Deploy as a web app executing as the deploying account, restricted to UC San Diego users. + +The app also enforces the active `Staff Access` allowlist on every server call. Roles are `staff`, `trainer`, and `administrator`; only trainers and administrators can record laser training. + +FabMan synchronization is deliberately not claimed by this MVP. A recorded approval displays `Not connected` until credentials and resource mapping are configured. diff --git a/Kiosk-v2/apps-script-staff/StaffCore.gs b/Kiosk-v2/apps-script-staff/StaffCore.gs new file mode 100644 index 0000000..30aeaad --- /dev/null +++ b/Kiosk-v2/apps-script-staff/StaffCore.gs @@ -0,0 +1,137 @@ +function staffClean_(value, maxLength) { + return String(value == null ? "" : value).replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength); +} + +function staffTrue_(value) { + return ["true", "1", "yes"].indexOf(String(value || "").trim().toLowerCase()) !== -1; +} + +function staffMissingHeaders_(actual, required) { + const present = (actual || []).map(function (header) { return String(header || "").trim(); }); + return (required || []).filter(function (header) { return present.indexOf(header) === -1; }); +} + +function staffPreferredName_(displayName) { + const cleaned = staffClean_(displayName, 120); + return cleaned ? cleaned.split(/\s+/)[0] : "Member"; +} + +function staffPrivateName_(displayName) { + const cleaned = staffClean_(displayName, 120); + if (!cleaned) return "Member"; + const parts = cleaned.split(/\s+/); + return parts.length > 1 ? parts[0] + " " + parts[parts.length - 1].charAt(0).toUpperCase() + "." : parts[0]; +} + +function staffIdentifierHint_(value) { + const cleaned = staffClean_(value, 120); + return cleaned.length >= 4 ? "ID ending " + cleaned.slice(-4) : ""; +} + +function staffToolLabel_(toolKey) { + const cleaned = staffClean_(toolKey, 80).toLowerCase(); + if (cleaned === "epilog_laser_cutter") return "Laser cutter"; + return cleaned.split(/[_-]+/).filter(Boolean).map(function (word) { return word.charAt(0).toUpperCase() + word.slice(1); }).join(" "); +} + +function staffRoleLabel_(role) { + const cleaned = staffClean_(role, 80).toLowerCase(); + if (cleaned.indexOf("faculty") !== -1) return "Faculty"; + if (cleaned.indexOf("staff") !== -1) return "Staff"; + if (cleaned.indexOf("student") !== -1 || cleaned.indexOf("postdoc") !== -1) return "Student"; + return "Visitor"; +} + +function staffAttentionFlags_(registration) { + if (!registration) return []; + const flags = []; + const accountStatus = staffClean_(registration.Status, 80).toLowerCase(); + const waiverStatus = staffClean_(registration["DocuSign Status"], 120).toLowerCase(); + if (["unreviewed", "incomplete", "pending", "pending_waiver_review"].indexOf(accountStatus) !== -1) flags.push("Account incomplete"); + if (waiverStatus && !/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus)) flags.push("Waiver verification pending"); + return flags; +} + +function staffVisitFlagDetails_(value) { + const flags = staffClean_(value, 160).split(",").map(function (flag) { return flag.trim(); }).filter(Boolean); + const manual = flags.some(function (flag) { return flag.toLowerCase() === "manual check-in"; }); + return { + flags: flags.filter(function (flag) { return flag.toLowerCase() !== "manual check-in"; }), + checkInMethod: manual ? "Staff check-in" : "", + }; +} + +function staffTodayKey_(date, timeZone) { + return Utilities.formatDate(date, timeZone, "yyyy-MM-dd"); +} + +function staffDerivePresence_(people, visits, training, timeZone) { + const byPerson = {}; + people.forEach(function (person) { + byPerson[person["Person ID"]] = { + personId: person["Person ID"], + name: staffPreferredName_(person["Display Name"]), + role: staffRoleLabel_(person.Role), + tools: [], + }; + }); + training.forEach(function (record) { + if (!byPerson[record["Person ID"]] || String(record.Status).toLowerCase() !== "approved") return; + byPerson[record["Person ID"]].tools.push(staffClean_(record.Tool, 80)); + }); + + const today = staffTodayKey_(new Date(), timeZone); + const events = visits.map(function (visit, index) { + const at = new Date(visit["Check In At"]); + return { visit: visit, at: at, index: index }; + }).filter(function (entry) { + return !isNaN(entry.at.getTime()) && staffTodayKey_(entry.at, timeZone) === today; + }).sort(function (a, b) { return a.at.getTime() - b.at.getTime() || a.index - b.index; }); + + const state = {}; + events.forEach(function (entry) { + const visit = entry.visit; + const personId = visit["Person ID"]; + const eventType = String(visit["Event Type"] || ""); + if (!byPerson[personId]) return; + if (eventType === "User Checkin" || eventType === "Staff Reopen") { + state[personId] = { present: true, checkedInAt: entry.at, event: visit }; + } else if (eventType === "Staff Checkout") { + state[personId] = { present: false, checkedInAt: state[personId] ? state[personId].checkedInAt : entry.at, event: visit }; + } + }); + + const present = []; + const left = []; + Object.keys(state).forEach(function (personId) { + const current = state[personId]; + const person = byPerson[personId]; + const visitDetails = staffVisitFlagDetails_(current.event.Flags); + const item = { + personId: personId, + name: person.name, + role: person.role, + tools: person.tools.filter(Boolean), + checkedInAt: current.checkedInAt.toISOString(), + flags: visitDetails.flags, + checkInMethod: visitDetails.checkInMethod, + }; + (current.present ? present : left).push(item); + }); + present.sort(function (a, b) { return b.checkedInAt.localeCompare(a.checkedInAt); }); + left.sort(function (a, b) { return b.checkedInAt.localeCompare(a.checkedInAt); }); + return { present: present, left: left }; +} + +if (typeof module !== "undefined") module.exports = { + staffClean_: staffClean_, + staffTrue_: staffTrue_, + staffMissingHeaders_: staffMissingHeaders_, + staffPreferredName_: staffPreferredName_, + staffPrivateName_: staffPrivateName_, + staffIdentifierHint_: staffIdentifierHint_, + staffToolLabel_: staffToolLabel_, + staffRoleLabel_: staffRoleLabel_, + staffAttentionFlags_: staffAttentionFlags_, + staffVisitFlagDetails_: staffVisitFlagDetails_, +}; diff --git a/Kiosk-v2/apps-script-staff/appsscript.json b/Kiosk-v2/apps-script-staff/appsscript.json new file mode 100644 index 0000000..17d54ea --- /dev/null +++ b/Kiosk-v2/apps-script-staff/appsscript.json @@ -0,0 +1,11 @@ +{ + "timeZone": "America/Los_Angeles", + "dependencies": {}, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "oauthScopes": [ + "https://www.googleapis.com/auth/script.external_request", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/userinfo.email" + ] +} diff --git a/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs b/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs new file mode 100644 index 0000000..d4f343e --- /dev/null +++ b/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs @@ -0,0 +1,63 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const test = require("node:test"); +const vm = require("node:vm"); + +const code = fs.readFileSync(require.resolve("../StaffCore.gs"), "utf8"); +const sandbox = { module: { exports: {} } }; +vm.runInNewContext(code, sandbox); +const core = sandbox.module.exports; + +test("preferred name is privacy-limited to the first displayed name", () => { + assert.equal(core.staffPreferredName_("Maya Chen"), "Maya"); + assert.equal(core.staffPreferredName_(""), "Member"); +}); + +test("staff search identity shows only a last initial", () => { + assert.equal(core.staffPrivateName_("Alexandra Martinez"), "Alexandra M."); + assert.equal(core.staffPrivateName_("Alex"), "Alex"); +}); + +test("identifier hints reveal only the final four characters", () => { + assert.equal(core.staffIdentifierHint_("A12345678"), "ID ending 5678"); + assert.equal(core.staffIdentifierHint_("23"), ""); +}); + +test("legacy tool keys become readable approval labels", () => { + assert.equal(core.staffToolLabel_("epilog_laser_cutter"), "Laser cutter"); + assert.equal(core.staffToolLabel_("wood_shop"), "Wood Shop"); +}); + +test("roles collapse to the small staff-facing set", () => { + assert.equal(core.staffRoleLabel_("Graduate student"), "Student"); + assert.equal(core.staffRoleLabel_("Scripps staff"), "Staff"); + assert.equal(core.staffRoleLabel_("Faculty"), "Faculty"); +}); + +test("boolean and text normalization are conservative", () => { + assert.equal(core.staffTrue_("YES"), true); + assert.equal(core.staffTrue_("no"), false); + assert.equal(core.staffClean_(" a\n b ", 20), "a b"); +}); + +test("read-only sheet validation allows added and reordered columns", () => { + const required = ["Registration ID", "Person ID", "Status", "Source"]; + const actual = ["Status", "Anticipated Graduation", "Registration ID", "Source", "Person ID"]; + assert.deepEqual(Array.from(core.staffMissingHeaders_(actual, required)), []); + assert.deepEqual(Array.from(core.staffMissingHeaders_(actual, required.concat("DocuSign Status"))), ["DocuSign Status"]); +}); + +test("attention flags distinguish account and waiver follow-up", () => { + assert.deepEqual(Array.from(core.staffAttentionFlags_({ Status: "Incomplete", "DocuSign Status": "Awaiting verification" })), ["Account incomplete", "Waiver verification pending"]); + assert.deepEqual(Array.from(core.staffAttentionFlags_({ Status: "Active", "DocuSign Status": "Signed" })), []); + assert.deepEqual(Array.from(core.staffAttentionFlags_(null)), []); +}); + +test("manual check-in is provenance rather than an attention flag", () => { + const details = core.staffVisitFlagDetails_("Manual check-in, Unknown card"); + assert.deepEqual(Array.from(details.flags), ["Unknown card"]); + assert.equal(details.checkInMethod, "Staff check-in"); + const normal = core.staffVisitFlagDetails_("Duplicate tap"); + assert.deepEqual(Array.from(normal.flags), ["Duplicate tap"]); + assert.equal(normal.checkInMethod, ""); +}); From e81d73c217e3d479e4b9eb6ece37333c54aeae0e Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Thu, 13 Aug 2026 14:04:01 -0700 Subject: [PATCH 07/26] Add institutional recovery package --- Kiosk-v2/.env.example | 6 + Kiosk-v2/README.md | 2 + Kiosk-v2/deploy/pi/bin/sandbox-kiosk-display | 27 +++++ Kiosk-v2/deploy/pi/scanner.env.example | 15 +++ .../pi/systemd/sandbox-kiosk-bridge.service | 15 +++ .../pi/systemd/sandbox-kiosk-display.service | 14 +++ .../pi/systemd/sandbox-kiosk-web.service | 14 +++ Kiosk-v2/docs/disaster-recovery.md | 103 ++++++++++++++++++ Kiosk-v2/docs/recovery-manifest-v1.md | 74 +++++++++++++ 9 files changed, 270 insertions(+) create mode 100755 Kiosk-v2/deploy/pi/bin/sandbox-kiosk-display create mode 100644 Kiosk-v2/deploy/pi/scanner.env.example create mode 100644 Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-bridge.service create mode 100644 Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-display.service create mode 100644 Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-web.service create mode 100644 Kiosk-v2/docs/disaster-recovery.md create mode 100644 Kiosk-v2/docs/recovery-manifest-v1.md diff --git a/Kiosk-v2/.env.example b/Kiosk-v2/.env.example index d4f39cf..9909ecd 100644 --- a/Kiosk-v2/.env.example +++ b/Kiosk-v2/.env.example @@ -5,3 +5,9 @@ CARD_UID_HMAC_SECRET= # Public URL of the deployed Apps Script registration web app. The kiosk hides # the registration QR and link until this is set at build time. NEXT_PUBLIC_REGISTRATION_URL= + +# Public DocuSign PowerForm URL displayed on the waiver-required screen. +NEXT_PUBLIC_WAIVER_URL= + +# Normally omitted because localhost uses the loopback scanner bridge. +NEXT_PUBLIC_SCANNER_WS_URL= diff --git a/Kiosk-v2/README.md b/Kiosk-v2/README.md index 4f2ef2b..0f51788 100644 --- a/Kiosk-v2/README.md +++ b/Kiosk-v2/README.md @@ -5,6 +5,8 @@ Interactive kiosk prototype and production technical spike for the Scripps Sandb - `app/` contains the 1920×1080 kiosk interface and interactive failure/exception flows. - `bridge/` contains the Raspberry Pi serial-to-browser reader bridge. - `docs/production-spec.md` captures the agreed workflows, permissions, data model, failure behavior, migration, and milestones. +- `docs/recovery-manifest-v1.md` identifies the institutional recovery point. +- `docs/disaster-recovery.md` is the fresh-Pi, ESP32, Apps Script, and data restore runbook. ## Prerequisites diff --git a/Kiosk-v2/deploy/pi/bin/sandbox-kiosk-display b/Kiosk-v2/deploy/pi/bin/sandbox-kiosk-display new file mode 100755 index 0000000..db460f5 --- /dev/null +++ b/Kiosk-v2/deploy/pi/bin/sandbox-kiosk-display @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -u + +export DISPLAY=:0 +export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus + +for attempt in $(seq 1 90); do + if /usr/bin/curl -fsS --max-time 2 http://127.0.0.1:3000 >/dev/null 2>&1 && \ + /usr/bin/curl -fsS --max-time 2 http://127.0.0.1:8765/health 2>/dev/null | \ + /usr/bin/grep -q '"backend_ready":true'; then + break + fi + /usr/bin/sleep 1 +done + +/usr/bin/pkill -f '[k]iosk-maintenance-reboot' 2>/dev/null || true + +exec /usr/bin/chromium \ + --password-store=basic \ + --disable-gpu \ + --disable-gpu-compositing \ + --ozone-platform=x11 \ + --kiosk \ + --noerrdialogs \ + --disable-session-crashed-bubble \ + --user-data-dir=/tmp/kiosk-v2-chrome \ + http://127.0.0.1:3000 diff --git a/Kiosk-v2/deploy/pi/scanner.env.example b/Kiosk-v2/deploy/pi/scanner.env.example new file mode 100644 index 0000000..26d6042 --- /dev/null +++ b/Kiosk-v2/deploy/pi/scanner.env.example @@ -0,0 +1,15 @@ +# Copy to /home/sandbox/.config/sandbox-kiosk/scanner.env. +# Replace placeholders locally. Never commit the populated file. +SCANNER_CHECKIN_BACKEND=sheets +SHEETS_CREDENTIALS_PATH=/home/sandbox/.config/sandbox-kiosk/google-service-account.json +SHEETS_USER_DATABASE_URL=REPLACE_WITH_PRODUCTION_DATABASE_URL +SHEETS_WAIVER_URL=REPLACE_WITH_WAIVER_SPREADSHEET_URL +SHEETS_ACTIVITY_URL=REPLACE_WITH_PRODUCTION_DATABASE_URL +CARD_UID_HMAC_SECRET=REPLACE_WITH_SECRET_FROM_CREDENTIAL_REGISTER +CARD_LINK_STAFF_IDS=REPLACE_WITH_COMMA_SEPARATED_APPROVED_IDS +CARD_LINK_SESSION_SECONDS=300 + +# Optional operational tuning. +SHEETS_CACHE_SECONDS=300 +SHEETS_ACTIVITY_CACHE_SECONDS=3600 +SCANNER_DUPLICATE_SECONDS=15 diff --git a/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-bridge.service b/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-bridge.service new file mode 100644 index 0000000..891d60b --- /dev/null +++ b/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-bridge.service @@ -0,0 +1,15 @@ +[Unit] +Description=Scripps Sandbox RFID and Google Sheets bridge +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/sandbox/Check-In-v2/Kiosk-v2/bridge +EnvironmentFile=/home/sandbox/.config/sandbox-kiosk/scanner.env +ExecStart=/home/sandbox/Check-In-v2/Kiosk-v2/bridge/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8765 +Restart=always +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-display.service b/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-display.service new file mode 100644 index 0000000..f7600ae --- /dev/null +++ b/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-display.service @@ -0,0 +1,14 @@ +[Unit] +Description=Scripps Sandbox full-screen kiosk +After=sandbox-kiosk-web.service sandbox-kiosk-bridge.service +Requires=sandbox-kiosk-web.service sandbox-kiosk-bridge.service + +[Service] +Type=simple +ExecStart=/home/sandbox/.local/bin/sandbox-kiosk-display +Restart=on-failure +RestartSec=3 +TimeoutStopSec=8 + +[Install] +WantedBy=default.target diff --git a/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-web.service b/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-web.service new file mode 100644 index 0000000..2743cd4 --- /dev/null +++ b/Kiosk-v2/deploy/pi/systemd/sandbox-kiosk-web.service @@ -0,0 +1,14 @@ +[Unit] +Description=Scripps Sandbox kiosk web interface +After=network.target + +[Service] +Type=simple +WorkingDirectory=/home/sandbox/Check-In-v2/Kiosk-v2 +Environment=PATH=/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/local/bin/npm start -- --host 127.0.0.1 +Restart=always +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/Kiosk-v2/docs/disaster-recovery.md b/Kiosk-v2/docs/disaster-recovery.md new file mode 100644 index 0000000..cf9e885 --- /dev/null +++ b/Kiosk-v2/docs/disaster-recovery.md @@ -0,0 +1,103 @@ +# Check-in kiosk disaster recovery + +Use this procedure when the Pi, SD card, ESP32, or kiosk software must be +replaced. Work from institutional accounts. Do not restore from an employee's +personal computer when the GitHub release and shared-drive package are +available. + +## Before an incident + +1. Keep `scripps-sandbox@ucsd.edu` able to access the production database, + waiver sheet, Apps Script projects, DocuSign configuration, shared drive, and + Raspberry Pi Connect. +2. Keep the `ScriptsSandbox` GitHub account and at least one second institutional + administrator recoverable through UCSD-controlled contact methods. +3. Store credential values only in `07 Credentials Register`. The repository and + this runbook contain names and locations, not values. +4. After a material release, create dated database and waiver snapshots in the + recovery folder and record the source commit here. +5. Test a spare SD card at least once per quarter and after changing startup, + authentication, or storage. + +## Fresh Pi rebuild + +1. Image a known-good microSD card with the supported Raspberry Pi OS Desktop. +2. Create the `sandbox` user and enable desktop autologin. Confirm its UID is + `1000`, or update the launcher DBus path before installation. +3. Enroll the device in the institutional Raspberry Pi Connect account as + `Check-In Computer v2` or a clearly documented replacement name. +4. Install Git, Node.js 22.13 or newer, npm, Python 3, `python3-venv`, Chromium, + and curl. +5. Clone `https://github.com/ScriptsSandbox/Check-In.git` to + `/home/sandbox/Check-In-v2` and check out the recorded recovery release + branch or commit. +6. In `Kiosk-v2`, run `npm ci`, `npm run test:unit`, `npm run test:pi`, and + `npm run build`. +7. In `Kiosk-v2/bridge`, create `.venv`, install `requirements.txt`, and run the + bridge test suite. +8. Recreate `Kiosk-v2/.env.production` from `.env.example` and the credential + register. Recreate `~/.config/sandbox-kiosk/scanner.env` from + `deploy/pi/scanner.env.example`. Copy the service-account JSON to the path + named there. Restrict all three files to the `sandbox` user. +9. Copy the three unit files from `deploy/pi/systemd` to + `~/.config/systemd/user/`. Copy `deploy/pi/bin/sandbox-kiosk-display` to + `~/.local/bin/` and make it executable. +10. Run `systemctl --user daemon-reload`, enable the bridge, web, and display + units, and start them. Confirm all three are active. +11. Confirm bridge health reports a connected reader and `backend_ready: true`. + Confirm Chromium opens the kiosk automatically without browser chrome. + +## ESP32 recovery + +1. Build `ESP-32/src/scanner.ino` with the board and library versions recorded + in the institutional credentials/inventory folder. +2. Flash the connected ESP32 over USB. +3. Confirm it prints exactly one uppercase hexadecimal UID per scan at 115200 + baud and displays the Sandbox-colored tap/look-up sequence. +4. Never place Google credentials, person data, or authorization rules on the + ESP32. + +## Apps Script recovery + +1. Sign in as `scripps-sandbox@ucsd.edu`. +2. Restore registration from `apps-script-registration` and staff desk from + `apps-script-staff`. Preserve the existing project when possible so approved + URLs remain stable. +3. Re-enter Script Properties from the credential register. Do not paste them + into GitHub issues, documentation, or source files. +4. Deploy registration with the approved public access policy. Deploy the staff + desk only to UC San Diego users and retain server-side `Staff Access` + enforcement. +5. Verify a fictional registration against a test database before reconnecting + production. Remove the fictional record after verification. + +## Data restore + +1. Prefer the live Google file and revision history when the problem is an + accidental edit. +2. Use the dated institutional snapshots only when the live file is unavailable + or irreparably damaged. +3. Make a new copy of the selected snapshot; never turn the archival snapshot + itself into the live file. +4. Grant only the production Apps Scripts, service accounts, and approved staff + the access they require. +5. Update spreadsheet references in the Pi and Apps Script properties during a + scheduled outage. Restart the bridge and verify its warm-up before reopening. + +## Acceptance test + +Do not reopen until all of these pass: + +- Cold boot reaches the home screen automatically. +- Raspberry Pi Connect screen sharing and remote shell work. +- A known waived card creates exactly one successful visit. +- Rapid repeated taps create no duplicate visit. +- PID, TSN, and employee-ID manual check-in resolve correctly. +- A missing waiver blocks the visit and shows the waiver QR. +- Unknown-card staff linking requires an authorized staff card. +- Disconnecting and reconnecting the reader recovers without rebooting. +- A brief network failure never produces a false success. +- Registration and staff desk open under their intended access policies. + +Record the restore date, release commit, operator, results, and any exceptions in +the institutional recovery folder. diff --git a/Kiosk-v2/docs/recovery-manifest-v1.md b/Kiosk-v2/docs/recovery-manifest-v1.md new file mode 100644 index 0000000..4430be0 --- /dev/null +++ b/Kiosk-v2/docs/recovery-manifest-v1.md @@ -0,0 +1,74 @@ +# Recovery manifest v1 + +Recorded: 2026-08-13 + +This manifest identifies the first institutional recovery point for the Scripps +Sandbox check-in system. It contains no credentials, raw card values, student +records, or private spreadsheet identifiers. + +## Ownership + +- Source code: `ScriptsSandbox/Check-In` on GitHub. +- Google recovery assets: `Scripps Sandbox Web & Data Infrastructure` shared + drive, under `04 SOPs and Runbooks/Check-In Kiosk Recovery`. +- Operational Google identity: `scripps-sandbox@ucsd.edu`. +- Credential values: institutional credential register only; never GitHub. + +## Version boundary + +- Pi kiosk, scanner bridge, and ESP32 display deployed on 2026-08-13 from + commit `242abcf256851f47dfec35ce27b075e821937c59`. +- The Pi worktree has a modified private `.env.production` and local pre-change + backup files. Those are intentionally not part of the release. +- The normalized Apps Script registration source and staff desk source were + preserved after the Pi commit because they deploy independently of the Pi. +- The recovery release branch must point at the commit containing this manifest, + the preserved Apps Script sources, and the captured Pi unit files. + +## Repositories and components + +| Component | Recovery source | +| --- | --- | +| Kiosk web UI | `Kiosk-v2/app` | +| Scanner/Sheets bridge | `Kiosk-v2/bridge` | +| Pi service definitions | `Kiosk-v2/deploy/pi/systemd` | +| Pi Chromium launcher | `Kiosk-v2/deploy/pi/bin/sandbox-kiosk-display` | +| ESP32 firmware | `ESP-32/src/scanner.ino` | +| Registration Apps Script | `Kiosk-v2/apps-script-registration` | +| Staff desk Apps Script | `Kiosk-v2/apps-script-staff` | +| Data migration and crosswalk controls | `Kiosk-v2/lib/sync` and `Kiosk-v2/docs` | + +## Live Pi inventory + +- Raspberry Pi Connect name: `Check-In Computer v2`. +- Checkout path: `/home/sandbox/Check-In-v2`. +- Pi deployed commit: `242abcf256851f47dfec35ce27b075e821937c59`. +- Enabled user services: + - `sandbox-kiosk-bridge.service` + - `sandbox-kiosk-web.service` + - `sandbox-kiosk-display.service` +- Bridge health: `http://127.0.0.1:8765/health`. +- Kiosk web endpoint: `http://127.0.0.1:3000`. + +## Data recovery point + +Dated 2026-08-13 copies of the normalized production database and the waiver +spreadsheet are stored in the institutional recovery folder and clearly marked +`DO NOT USE AS LIVE`. The original production database remains live and was not +moved or renamed. + +## Validation at capture + +- Apps Script registration tests: 12 passed. +- Staff desk tests: 9 passed. +- Kiosk/data unit tests: 28 passed. +- Pi tests: 2 passed. +- Production web build: passed. +- Pi commit and enabled service inventory: verified remotely. + +## Known continuity gap + +The live production spreadsheet was personally owned at capture time, with +`scripps-sandbox@ucsd.edu` as an editor. Schedule a controlled ownership move to +an appropriate UCSD shared drive after checking service-account and Apps Script +access. Do not change ownership during an incident. From edc490713584b4882c3daadf4bcdcf37ffb4a6a0 Mon Sep 17 00:00:00 2001 From: Codex Sites Date: Fri, 14 Aug 2026 08:46:08 -0700 Subject: [PATCH 08/26] Add kiosk revision display --- Kiosk-v2/app/globals.css | 100 +++++++++ Kiosk-v2/app/page.tsx | 209 +++++++++++++----- Kiosk-v2/apps-script-registration/Index.html | 125 +++++++++-- .../apps-script-registration/KioskProfile.gs | 100 +++++++++ Kiosk-v2/apps-script-registration/README.md | 1 + .../RegistrationCore.gs | 77 ++++++- .../test/registration-core.test.cjs | 11 +- Kiosk-v2/bridge/app.py | 64 ++++++ Kiosk-v2/bridge/apps_script_backend.py | 15 ++ Kiosk-v2/bridge/sheets_backend.py | 92 +++++++- .../bridge/tests/test_apps_script_backend.py | 19 ++ Kiosk-v2/bridge/tests/test_bridge_events.py | 14 ++ Kiosk-v2/bridge/tests/test_sheets_backend.py | 24 ++ Kiosk-v2/docs/kiosk-releases.md | 13 ++ Kiosk-v2/lib/kiosk-release.ts | 6 + Kiosk-v2/lib/profile-enrichment.ts | 135 +++++++++++ Kiosk-v2/tests/profile-enrichment.test.ts | 37 ++++ 17 files changed, 963 insertions(+), 79 deletions(-) create mode 100644 Kiosk-v2/apps-script-registration/KioskProfile.gs create mode 100644 Kiosk-v2/bridge/tests/test_apps_script_backend.py create mode 100644 Kiosk-v2/docs/kiosk-releases.md create mode 100644 Kiosk-v2/lib/kiosk-release.ts create mode 100644 Kiosk-v2/lib/profile-enrichment.ts create mode 100644 Kiosk-v2/tests/profile-enrichment.test.ts diff --git a/Kiosk-v2/app/globals.css b/Kiosk-v2/app/globals.css index 0e6cd85..4ab1b46 100644 --- a/Kiosk-v2/app/globals.css +++ b/Kiosk-v2/app/globals.css @@ -460,6 +460,81 @@ input:focus { border-color: var(--orange); } .choice-grid button { display: flex; justify-content: space-between; gap: 14px; min-height: 70px; padding: 18px 20px; border: 0; background: var(--gray); cursor: pointer; text-align: left; font-size: clamp(16px, 1.15vw, 22px); } .choice-grid button:hover, .choice-grid button.selected { background: var(--cream); color: var(--navy); } +.profile-shell { width: min(100%, 720px); } +.profile-card { + position: relative; + width: 100%; + padding: clamp(34px, 4.2vw, 58px); + border: 3px solid var(--navy); + background: var(--cream); + color: var(--navy); + box-shadow: 12px 12px 0 var(--orange); + animation: profile-card-in .42s cubic-bezier(.2,.82,.24,1) both; +} +@keyframes profile-card-in { + from { opacity: 0; transform: translateY(20px) scale(.975); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.profile-card-tab { + position: absolute; + top: -19px; + left: 28px; + padding: 8px 13px; + background: var(--navy); + color: var(--cream); + font: 800 11px/1 "Jost"; + letter-spacing: .15em; +} +.profile-card .eyebrow { margin-bottom: .9em; } +.profile-card h1 { font-size: clamp(42px, 4.35vw, 76px); line-height: .96; } +.profile-card .lede { max-width: 590px; color: #35485b; font-size: clamp(18px, 1.3vw, 25px); } +.profile-card .back { margin-bottom: clamp(20px, 2.5vh, 34px); color: var(--navy); } +.profile-card .solid-action { display: inline-flex; align-items: center; gap: 16px; margin-top: 30px; } +.profile-card .quiet-action { margin: 20px 0 0 14px; color: var(--navy); } +.profile-pulse { display: flex; height: 28px; align-items: flex-end; gap: 5px; margin-bottom: 24px; } +.profile-pulse i { width: 8px; background: var(--cyan); animation: profile-pulse 1s ease-in-out infinite alternate; } +.profile-pulse i:nth-child(1) { height: 10px; } +.profile-pulse i:nth-child(2) { height: 19px; animation-delay: .12s; } +.profile-pulse i:nth-child(3) { height: 28px; background: var(--orange); animation-delay: .24s; } +@keyframes profile-pulse { to { opacity: .45; transform: scaleY(.72); transform-origin: bottom; } } + +.profile-form { width: 100%; max-width: none; margin-top: clamp(28px, 3.2vh, 42px); } +.profile-fields { display: grid; gap: 12px; } +.profile-fields label { margin: 0; color: var(--navy); } +.profile-fields select { + display: block; + width: 100%; + min-height: 68px; + padding: 14px 52px 14px 18px; + border: 3px solid var(--navy); + border-radius: 0; + outline: 0; + background: #fffdf7; + color: var(--navy); + font: 650 clamp(18px, 1.3vw, 24px)/1.2 "Jost"; +} +.profile-fields select:focus { border-color: var(--orange); box-shadow: 0 0 0 4px rgba(247,147,30,.2); } +.profile-fields select option { background: #fffdf7; color: var(--navy); } +.profile-fields input { + padding: 16px 14px; + border: 3px solid var(--navy); + background: #fffdf7; + color: var(--navy); + font-size: clamp(22px, 2vw, 34px); + color-scheme: light; +} +.profile-form > .solid-action { margin-top: 34px; } +.profile-card .reader-offline-notice { color: var(--navy); } +.profile-role-grid { max-width: none; margin-top: 26px; gap: 2px; background: var(--navy); } +.profile-role-grid button { + min-height: 58px; + padding: 13px 16px; + background: #fffdf7; + color: var(--navy); + font-size: clamp(15px, 1vw, 19px); +} +.profile-role-grid button:hover, .profile-role-grid button:focus-visible { background: var(--orange); } + .onboarding-grid { display: grid; grid-template-columns: 1fr 210px; gap: clamp(30px, 4vw, 70px); align-items: center; margin: 28px 0; } .onboarding-grid ol, .onboarding-primary ol { margin: 24px 0 0; padding: 0; list-style: none; } .onboarding-grid li, .onboarding-primary li { display: flex; gap: 18px; padding: 10px 0; border-top: 1px solid rgba(242,238,227,.4); font-size: 18px; } @@ -565,6 +640,21 @@ input:focus { border-color: var(--orange); } .staff-poster b { position: relative; z-index: 1; font: 650 clamp(52px, 7vw, 128px)/.82 "Jost"; letter-spacing: -.07em; transform: rotate(-7deg); } .staff-panel > form { width: min(720px, 100%); max-width: none; margin: 0; padding: clamp(34px, 5vh, 70px) clamp(36px, 6vw, 100px); overflow-y: auto; } .staff-panel .demo-heading { margin-bottom: 18px; } +.release-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 5px 24px; + margin: 0 0 22px; + padding: 16px 18px; + border: 2px solid var(--navy); + background: #fffaf0; + box-shadow: 6px 6px 0 var(--orange); +} +.release-card div { display: flex; align-items: baseline; gap: 12px; } +.release-card small { font: 750 10px/1.2 "Jost"; letter-spacing: .14em; } +.release-card strong { font: 750 22px/1 "Jost"; letter-spacing: .02em; } +.release-card time { color: #53606b; font: 650 14px/1.2 "Jost"; } +.release-card p { grid-column: 1 / -1; margin: 0; color: #53606b; font-size: 14px; line-height: 1.3; } .staff-intro { max-width: 610px; margin: 0 0 22px; color: #53606b; font-size: 18px; line-height: 1.4; } .preset-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 26px; } .preset-row button { padding: 9px 13px; border: 1px solid var(--navy); background: transparent; color: var(--navy); cursor: pointer; font-weight: 650; } @@ -613,6 +703,10 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .staff-panel { grid-template-columns: 1fr; overflow-y: auto; } .staff-poster { display: none; } .staff-panel > form { overflow: visible; } + .release-card { grid-template-columns: 1fr; } + .release-card time { grid-row: 2; } + .release-card p { grid-column: 1; } + .profile-card { padding: 34px 26px; } } @media (max-height: 760px) and (min-width: 881px) { @@ -625,6 +719,12 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .home-copy.has-alert .primary-actions { margin-top: 18px; } .home-copy.has-alert .text-action { padding-block: 9px; } .success-closing-alert { margin-top: 14px; } + .profile-card { padding: 30px 34px; } + .profile-card h1 { font-size: clamp(38px, 4vw, 60px); } + .profile-card .lede { font-size: 18px; } + .profile-card .back { margin-bottom: 16px; } + .profile-role-grid { margin-top: 18px; } + .profile-role-grid button { min-height: 49px; padding: 10px 13px; font-size: 14px; } footer { bottom: 18px; } } diff --git a/Kiosk-v2/app/page.tsx b/Kiosk-v2/app/page.tsx index e3a8b45..2a293e1 100644 --- a/Kiosk-v2/app/page.tsx +++ b/Kiosk-v2/app/page.tsx @@ -2,6 +2,16 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { QRCodeSVG } from "qrcode.react"; +import { + PROFILE_ROLES, + affiliationOptions, + emptyProfile, + nextProfileQuestion, + normalizedProfileAnswer, + type ProfileField, + type ProfileSnapshot, +} from "@/lib/profile-enrichment"; +import { KIOSK_RELEASE } from "@/lib/kiosk-release"; type Screen = | "home" @@ -59,6 +69,8 @@ type ScannerResultEvent = { read_at: string; sequence: number; processing_ms?: number; + person_id?: string | null; + profile?: Partial; }; type ScannerEvent = ScannerDetectedEvent | ScannerResultEvent; @@ -70,15 +82,6 @@ const emptyAnnouncement: Announcement = { closingTime: "", }; -const affiliations = [ - "Undergraduate", - "Graduate student", - "Postdoc", - "Faculty", - "Staff", - "Visitor", -]; - function Arrow({ direction = "right" }: { direction?: "right" | "left" }) { return ; } @@ -102,8 +105,12 @@ export default function Home() { const [demoOpen, setDemoOpen] = useState(false); const [pid, setPid] = useState(""); const [checkInMethod, setCheckInMethod] = useState<"card" | "identifier">("card"); - const [affiliation, setAffiliation] = useState(""); - const [detail, setDetail] = useState(""); + const [profile, setProfile] = useState(emptyProfile); + const [profileSessionAvailable, setProfileSessionAvailable] = useState(false); + const [profileAnswer, setProfileAnswer] = useState(""); + const [profileOther, setProfileOther] = useState(""); + const [profileSaving, setProfileSaving] = useState(false); + const [profileError, setProfileError] = useState(""); const [linkIdentifier, setLinkIdentifier] = useState(""); const [linkTargetName, setLinkTargetName] = useState("Sandbox member"); const [linkError, setLinkError] = useState(""); @@ -121,6 +128,10 @@ export default function Home() { const demoControlsEnabled = process.env.NEXT_PUBLIC_KIOSK_DEMO === "true"; const screenRef = useRef("home"); const cardDetectedAtRef = useRef(null); + const profileQuestion = useMemo( + () => profileSessionAvailable ? nextProfileQuestion(profile) : null, + [profile, profileSessionAvailable], + ); useEffect(() => { screenRef.current = screen; @@ -218,17 +229,23 @@ export default function Home() { const delay = Math.max(0, minimumFeedbackMs - (performance.now() - detectedAt)); const timer = window.setTimeout(() => { if (scannerResult.outcome === "demo") { + setCountdown(8); setScreen("success"); return; } if (scannerResult.outcome === "success") { + const incomingProfile = { ...emptyProfile(), ...(scannerResult.profile || {}) }; + const hasQuestion = Boolean(scannerResult.person_id && nextProfileQuestion(incomingProfile)); setWelcomeName(scannerResult.display_name || "Sandbox member"); setVisitCount(scannerResult.visit_count); - setScreen("success"); + setProfile(incomingProfile); + setProfileSessionAvailable(Boolean(scannerResult.person_id)); + setCountdown(8); + setScreen(hasQuestion ? "profile" : "success"); } else if (scannerResult.outcome === "unknown_card") { setScreen("unknown-card"); - } else if (scannerResult.outcome === "unknown_identifier") { - setScreen("not-found"); + } else if (scannerResult.outcome === "unknown_identifier") { + setScreen("not-found"); } else if (scannerResult.outcome === "waiver_required") { setScreen("waiver-required"); } else { @@ -240,7 +257,6 @@ export default function Home() { useEffect(() => { if (screen !== "success") return; - setCountdown(8); const interval = window.setInterval(() => { setCountdown((value) => { if (value <= 1) { @@ -328,8 +344,12 @@ export default function Home() { setScreen("home"); setCheckInMethod("card"); setPid(""); - setAffiliation(""); - setDetail(""); + setProfile(emptyProfile()); + setProfileSessionAvailable(false); + setProfileAnswer(""); + setProfileOther(""); + setProfileSaving(false); + setProfileError(""); setLinkIdentifier(""); setLinkTargetName("Sandbox member"); setLinkError(""); @@ -415,6 +435,51 @@ export default function Home() { reset(); } + async function saveProfileAnswer(field: ProfileField, rawValue: string) { + const value = normalizedProfileAnswer(field, rawValue); + if (!value || profileSaving) return; + setProfileSaving(true); + setProfileError(""); + try { + let updatedProfile: ProfileSnapshot; + if (demoControlsEnabled && !profileSessionAvailable) { + updatedProfile = { ...profile, [field]: value }; + } else { + const response = await fetch("http://127.0.0.1:8765/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ field, value }), + }); + const result = await response.json() as { + ok?: boolean; + profile?: Partial; + detail?: string; + message?: string; + }; + if (!response.ok || !result.ok) { + throw new Error(result.detail || result.message || "That answer could not be saved."); + } + updatedProfile = { ...profile, ...(result.profile || {}), [field]: value }; + } + setProfile(updatedProfile); + setProfileAnswer(""); + setProfileOther(""); + if (!nextProfileQuestion(updatedProfile)) { + setCountdown(8); + setScreen("success"); + } + } catch (error) { + setProfileError(error instanceof Error ? error.message : "That answer could not be saved."); + } finally { + setProfileSaving(false); + } + } + + function skipProfileQuestions() { + setCountdown(8); + setScreen("success"); + } + const showBrand = screen !== "success"; return ( @@ -425,7 +490,13 @@ export default function Home() {
{timeLabel} - + {demoControlsEnabled && - ))} +
+
+ + +

ONE QUICK UPDATE

+

Your account is missing
a few details.

+

Before the confirmation screen, please answer a few short questions. We’ll only ask for information that’s missing.

+ +
-
)} {screen === "profile-detail" && ( -
- -

LAST ONE

-

Your program
or department?

-

This helps us understand who the Makerspace serves.

- { event.preventDefault(); setScreen("success"); }}> - - setDetail(event.target.value)} - placeholder="e.g. Scripps Oceanography" - autoFocus - /> - - - +
+
+ + + {profileQuestion ? <> +

{profileQuestion.eyebrow}

+

{profileQuestion.heading}

+

{profileQuestion.prompt}

+ {profileQuestion.field === "role" ? ( +
+ {PROFILE_ROLES.map((item) => ( + + ))} + {profileError &&

{profileError}

} +
+ ) : ( +
{ + event.preventDefault(); + const value = profileAnswer === "Other" ? `Other – ${profileOther}` : profileAnswer; + saveProfileAnswer(profileQuestion.field, value); + }}> +
+ {profileQuestion.field === "anticipatedGraduation" ? ( + <> + + setProfileAnswer(event.target.value)} /> + + ) : ( + <> + + + {profileAnswer === "Other" && ( + setProfileOther(event.target.value)} placeholder="Type your answer" /> + )} + + )} +
+ {profileError &&

{profileError}

} + +
+ )} + : null} + +
)} @@ -761,7 +860,7 @@ export default function Home() {
LOCAL CARD READER{scannerStatus === "demo" ? "Demo mode" : scannerStatus}
- + @@ -784,6 +883,14 @@ export default function Home() {
STAFF CONTROLFront-screen message
+
+
+ CURRENT KIOSK REV + {KIOSK_RELEASE.revision} +
+ +

{KIOSK_RELEASE.summary}

+

Keep it brief. The kiosk gives the announcement the scale and urgency of a temporary poster.

diff --git a/Kiosk-v2/apps-script-registration/Index.html b/Kiosk-v2/apps-script-registration/Index.html index b33e1ab..bf68d99 100644 --- a/Kiosk-v2/apps-script-registration/Index.html +++ b/Kiosk-v2/apps-script-registration/Index.html @@ -111,25 +111,17 @@

Create your Sandbox account.

month and year - +
@@ -161,12 +153,17 @@

Next: complete the liability waiver.

diff --git a/Kiosk-v2/apps-script-staff/README.md b/Kiosk-v2/apps-script-staff/README.md index a3c2b4b..0d6c65a 100644 --- a/Kiosk-v2/apps-script-staff/README.md +++ b/Kiosk-v2/apps-script-staff/README.md @@ -15,4 +15,6 @@ The app also enforces the active `Staff Access` allowlist on every server call. Any approved staff member can edit a member's profile from the person card. The update writes the role to `People` and the role-dependent affiliation and anticipated graduation to the latest `Registrations` row, with the staff reviewer and timestamp recorded. +Staff whose `Staff Access` row has `Card Linking Allowed` enabled (and administrators) also see **Connect cards**. It lists the newest registered, waiver-verified accounts with no active card. Selecting a person creates a 45-second `Kiosk Link Requests` handoff; the kiosk revalidates the request, connects the first card, records the authorizing staff account, and checks the member in. Existing-card accounts are deliberately excluded and must use the replacement-card workflow. + FabMan synchronization is deliberately not claimed by this MVP. A recorded approval displays `Not connected` until credentials and resource mapping are configured. diff --git a/Kiosk-v2/bridge/app.py b/Kiosk-v2/bridge/app.py index 593c7d0..7efc6f1 100644 --- a/Kiosk-v2/bridge/app.py +++ b/Kiosk-v2/bridge/app.py @@ -66,6 +66,7 @@ def __init__( self.pending_card_uid: str | None = None self.pending_card_expires_at = 0.0 self.card_link_identifier: str | None = None + self.group_link_request: dict[str, Any] | None = None self.profile_person_id: str | None = None self.profile_expires_at = 0.0 self.last_success_uid: str | None = None @@ -166,7 +167,18 @@ async def publish(self, uid: str) -> bool: and self.card_link_identifier is not None and self.card_link_is_active() ) - if resume_profile: + group_link_request = self.group_link_request if self.card_link_identifier is None else None + if group_link_request: + try: + result = await asyncio.to_thread(self.backend.complete_group_link, group_link_request, uid) + except Exception: + LOGGER.exception("Group-onboarding card connection failed") + result = CheckInResult(outcome="group_link_error", message="The card could not be connected. Ask staff to try again.") + if result.outcome == "group_link_error": + self.guard.forget(uid) + else: + self.group_link_request = None + elif resume_profile: result = replace( self.last_success_result, message="Your check-in was already recorded. Continue your profile update.", @@ -386,6 +398,10 @@ class CardLinkStart(BaseModel): identifier: str +class GroupLinkCancel(BaseModel): + request_id: str + + class ProfileAnswer(BaseModel): field: str value: str @@ -432,6 +448,45 @@ async def cancel_card_link() -> dict[str, bool]: return {"ok": True} +@app.get("/group-link/status") +async def group_link_status() -> dict[str, Any]: + if not isinstance(STATE.backend, SheetsCheckInBackend): + return {"ok": True, "active": False} + try: + request = await asyncio.to_thread(STATE.backend.pending_group_link_request) + except Exception: + LOGGER.exception("Group-onboarding status check failed") + raise HTTPException(status_code=503, detail="The group-onboarding queue could not be checked") + if not request: + STATE.group_link_request = None + return {"ok": True, "active": False} + STATE.group_link_request = request + return { + "ok": True, + "active": True, + "request_id": request.get("request_id"), + "display_name": request.get("display_name"), + "expires_at": request.get("expires_at"), + } + + +@app.post("/group-link/cancel") +async def cancel_group_link(request: GroupLinkCancel) -> dict[str, bool]: + if not isinstance(STATE.backend, SheetsCheckInBackend): + raise HTTPException(status_code=409, detail="Group onboarding is unavailable") + request_id = request.request_id.strip() + if not request_id: + raise HTTPException(status_code=422, detail="A request ID is required") + try: + await asyncio.to_thread(STATE.backend.cancel_group_link_request, request_id) + except Exception: + LOGGER.exception("Group-onboarding cancellation failed") + raise HTTPException(status_code=503, detail="The request could not be cancelled") + if STATE.group_link_request and STATE.group_link_request.get("request_id") == request_id: + STATE.group_link_request = None + return {"ok": True} + + @app.post("/scanner/allow-repeat") async def allow_repeat() -> dict[str, bool]: return {"ok": True, "will_resume_profile": STATE.allow_intentional_repeat()} diff --git a/Kiosk-v2/bridge/sheets_backend.py b/Kiosk-v2/bridge/sheets_backend.py index a34eb4b..d93ca12 100644 --- a/Kiosk-v2/bridge/sheets_backend.py +++ b/Kiosk-v2/bridge/sheets_backend.py @@ -40,6 +40,9 @@ def waiver_records(self) -> list[dict[str, Any]]: ... def activity_rows(self) -> list[list[Any]]: ... def append_activity(self, row: list[Any]) -> None: ... def update_user_card(self, identifier: str, card_uid: str) -> dict[str, Any]: ... + def update_user_card_by_person(self, person_id: str, card_uid: str) -> dict[str, Any]: ... + def pending_group_link_request(self) -> dict[str, Any] | None: ... + def mark_group_link_request(self, request_id: str, status: str, message: str) -> None: ... def update_profile(self, person_id: str, field: str, value: str) -> dict[str, str]: ... def card_digest(self, card_uid: str) -> str: ... @@ -57,6 +60,22 @@ def normalize_card_uid(value: Any) -> str: return str(value or "").strip().upper() +def _sheet_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError: + pass + for pattern in ("%m/%d/%Y %H:%M:%S", "%m/%d/%Y %I:%M:%S %p", "%m/%d/%Y %H:%M"): + try: + return datetime.strptime(text, pattern) + except ValueError: + continue + return None + + def normalized_user_identifiers(record: dict[str, Any]) -> set[str]: values = record.get("Identifiers") or [record.get("Student ID")] if isinstance(values, str): @@ -116,6 +135,7 @@ def __init__( self.activity_cache_seconds = activity_cache_seconds self._lock = Lock() self._people_sheet: Any = None + self._database: Any = None self._identifiers_sheet: Any = None self._cards_sheet: Any = None self._registrations_sheet: Any = None @@ -156,6 +176,7 @@ def _connect(self) -> None: client = gspread.service_account(filename=self.credentials_path) database = client.open_by_key(self.database_id) + self._database = database self._people_sheet = database.worksheet("People") self._identifiers_sheet = database.worksheet("Identifiers") self._cards_sheet = database.worksheet("Cards") @@ -310,6 +331,81 @@ def update_user_card(self, identifier: str, card_uid: str) -> dict[str, Any]: self._cache_expires_at = time.monotonic() + self.cache_seconds return dict(record) + def update_user_card_by_person(self, person_id: str, card_uid: str) -> dict[str, Any]: + digest = self.card_digest(card_uid) + normalized_uid = normalize_card_uid(card_uid) + with self._lock: + self._refresh_people_if_needed() + users = self._users or [] + record = next((user for user in users if str(user.get("Person ID", "")).strip() == str(person_id).strip()), None) + if record is None: + raise ValueError("That active Sandbox account could not be found.") + if normalized_card_digests(record): + raise ValueError("This account already has an active card. Use the replacement-card workflow instead.") + if any(user_has_card_digest(user, digest) for user in users): + raise ValueError("That card is already connected to an account.") + self._connect() + headers = self._cards_sheet.row_values(1) + changed_at = datetime.now().isoformat(timespec="seconds") + card_values = { + "Card ID": "card_" + uuid4().hex, + "Person ID": record["Person ID"], + "Card Digest": digest, + "Last Four": normalized_uid[-4:], + "Status": "Active", + "Linked At": changed_at, + "Disabled At": "", + "Source": "Kiosk v2 group onboarding", + "Notes": "First card connected from Staff Desk queue", + } + self._cards_sheet.append_row([card_values.get(header, "") for header in headers], value_input_option="USER_ENTERED") + record["Card Digest"] = digest + record["Card Digests"] = (digest,) + self._cache_expires_at = time.monotonic() + self.cache_seconds + return dict(record) + + def pending_group_link_request(self) -> dict[str, Any] | None: + with self._lock: + self._connect() + try: + sheet = self._database.worksheet("Kiosk Link Requests") + except Exception: + return None + records = sheet.get_all_records(numericise_ignore=["all"]) + now = datetime.now() + for record in reversed(records): + if str(record.get("Status", "")).strip().lower() != "pending": + continue + expires_at = _sheet_datetime(record.get("Expires At")) + if expires_at is not None and expires_at <= now: + continue + return { + "request_id": str(record.get("Request ID", "")).strip(), + "person_id": str(record.get("Person ID", "")).strip(), + "display_name": str(record.get("Display Name", "")).strip(), + "requested_by": str(record.get("Requested By", "")).strip(), + "expires_at": expires_at.isoformat(timespec="seconds") if expires_at else "", + } + return None + + def mark_group_link_request(self, request_id: str, status: str, message: str) -> None: + with self._lock: + self._connect() + sheet = self._database.worksheet("Kiosk Link Requests") + headers = sheet.row_values(1) + rows = sheet.get_all_values() + id_column = headers.index("Request ID") + row_number = next((index + 1 for index in range(len(rows) - 1, 0, -1) if len(rows[index]) > id_column and str(rows[index][id_column]).strip() == request_id), 0) + if not row_number: + raise ValueError("That kiosk request could not be found.") + updates = { + "Status": status, + "Completed At": datetime.now().isoformat(timespec="seconds") if status.lower() != "pending" else "", + "Message": message, + } + for header, value in updates.items(): + sheet.update_cell(row_number, headers.index(header) + 1, value) + def update_profile(self, person_id: str, field: str, value: str) -> dict[str, str]: field_headers = { "role": ("people", "Role"), @@ -441,6 +537,52 @@ def update_profile(self, person_id: str, field: str, value: str) -> CheckInResul message="Profile updated.", ) + def pending_group_link_request(self) -> dict[str, Any] | None: + return self.provider.pending_group_link_request() + + def cancel_group_link_request(self, request_id: str) -> None: + self.provider.mark_group_link_request(request_id, "Cancelled", "Cancelled at the kiosk") + + def complete_group_link(self, request: dict[str, Any], card_uid: str) -> CheckInResult: + request_id = str(request.get("request_id", "")).strip() + person_id = str(request.get("person_id", "")).strip() + requested_by = str(request.get("requested_by", "")).strip() or "Authorized staff" + current = self.provider.pending_group_link_request() + if not request_id or not current or current.get("request_id") != request_id or current.get("person_id") != person_id: + return CheckInResult(outcome="group_link_error", message="That card-connection request expired. Ask staff to select the account again.") + target = next((user for user in self.provider.user_records() if str(user.get("Person ID", "")).strip() == person_id), None) + if target is None: + return CheckInResult(outcome="group_link_error", message="That active Sandbox account could not be found.") + if normalized_card_digests(target): + self.provider.mark_group_link_request(request_id, "Rejected", "Account already has an active card") + return CheckInResult(outcome="group_link_error", message="This account already has an active card. Ask staff to use the replacement-card workflow.") + if not self._waiver_found(target): + self.provider.mark_group_link_request(request_id, "Rejected", "Waiver no longer verified") + return CheckInResult(outcome="group_link_error", message="A signed waiver could not be verified. Please see staff.") + try: + linked = self.provider.update_user_card_by_person(person_id, card_uid) + except ValueError as error: + return CheckInResult(outcome="group_link_error", message=str(error)) + linked_at = self.local_datetime().isoformat(timespec="seconds") + self.provider.append_activity([ + "visit_" + uuid4().hex, person_id, linked_at, "Card Linked", requested_by, "Group onboarding", + "First card connected from Staff Desk queue", "Kiosk v2", "", + ]) + check_in = self._check_in_user(linked, time.monotonic(), {}) + if check_in.outcome != "success": + self.provider.mark_group_link_request(request_id, "Error", check_in.message or "Check-in failed after card connection") + return CheckInResult(outcome="group_link_error", display_name=check_in.display_name, message=check_in.message) + self.provider.mark_group_link_request(request_id, "Completed", "Card connected and check-in recorded") + return CheckInResult( + outcome="group_card_linked", + display_name=check_in.display_name, + message="Card connected and check-in recorded.", + visit_count=check_in.visit_count, + person_id=check_in.person_id, + profile=check_in.profile, + timings_ms=check_in.timings_ms, + ) + def link_card(self, identifier: str, card_uid: str, staff_card_uid: str, designated_staff_ids: set[str]) -> CheckInResult: total_started = time.monotonic() timings: dict[str, int] = {} @@ -481,6 +623,15 @@ def link_card(self, identifier: str, card_uid: str, staff_card_uid: str, designa timings["total"] = elapsed_ms(total_started) return CheckInResult(outcome="card_linked", display_name=display_name, message="Card connected. The member can now check in.", timings_ms=timings) + def _waiver_found(self, user: dict[str, Any]) -> bool: + user_ids = normalized_user_identifiers(user) + user_email = normalize_email(user.get("Email Address")) + return any( + (normalize_person_id(waiver.get("A_Number")) in user_ids) + or (bool(user_email) and normalize_email(waiver.get("Email")) == user_email) + for waiver in self.provider.waiver_records() + ) + def _check_in_user(self, user: dict[str, Any], total_started: float, timings: dict[str, int]) -> CheckInResult: user_ids = normalized_user_identifiers(user) user_email = normalize_email(user.get("Email Address")) diff --git a/Kiosk-v2/bridge/tests/test_sheets_backend.py b/Kiosk-v2/bridge/tests/test_sheets_backend.py index be0859a..3c7fee5 100644 --- a/Kiosk-v2/bridge/tests/test_sheets_backend.py +++ b/Kiosk-v2/bridge/tests/test_sheets_backend.py @@ -19,6 +19,8 @@ def __init__(self, users=None, waivers=None, existing_activity=None): ]] self.appended_rows = [] self.calls = {"users": 0, "waivers": 0, "activity": 0, "append": 0, "card_update": 0} + self.group_request = None + self.group_updates = [] @staticmethod def card_digest(card_uid): @@ -56,6 +58,27 @@ def update_user_card(self, identifier, card_uid): matches[0]["Replaced Card Count"] = replaced_count return dict(matches[0]) + def update_user_card_by_person(self, person_id, card_uid): + target = next((row for row in self.users if row.get("Person ID") == person_id), None) + if not target: + raise ValueError("That active Sandbox account could not be found.") + if target.get("Card Digests"): + raise ValueError("This account already has an active card. Use the replacement-card workflow instead.") + digest = self.card_digest(card_uid) + if any(user_has_card_digest(row, digest) for row in self.users): + raise ValueError("That card is already connected to an account.") + target["Card Digest"] = digest + target["Card Digests"] = (digest,) + return dict(target) + + def pending_group_link_request(self): + return dict(self.group_request) if self.group_request else None + + def mark_group_link_request(self, request_id, status, message): + self.group_updates.append((request_id, status, message)) + if status.lower() != "pending": + self.group_request = None + def update_profile(self, person_id, field, value): user = next((row for row in self.users if row.get("Person ID") == person_id), None) if not user: @@ -363,3 +386,32 @@ def test_duplicate_member_card_is_rejected(): result = backend_for(provider).link_card("A12345678", "NEWCARD", "STAFFCARD", {"A87654321"}) assert result.outcome == "card_link_error" assert provider.calls["append"] == 0 + + +def test_group_onboarding_connects_first_card_and_checks_member_in(): + target = member(card="", person_id="person_member", student_id="A12345678") + provider = FakeProvider(users=[target], waivers=[signed_waiver()]) + provider.group_request = { + "request_id": "link_1", "person_id": "person_member", "display_name": "Test Maker", + "requested_by": "staff@example.edu", "expires_at": "2026-08-14T15:00:00", + } + result = backend_for(provider).complete_group_link(provider.group_request, "NEWCARD") + assert result.outcome == "group_card_linked" + assert result.display_name == "Test Maker" + assert [row[3] for row in provider.appended_rows] == ["Card Linked", "User Checkin"] + assert provider.group_updates[-1][1] == "Completed" + assert user_has_card_digest(provider.users[0], provider.card_digest("NEWCARD")) + + +def test_group_onboarding_refuses_an_account_with_an_active_card(): + target = member(card="OLDCARD", person_id="person_member", student_id="A12345678") + provider = FakeProvider(users=[target], waivers=[signed_waiver()]) + provider.group_request = { + "request_id": "link_1", "person_id": "person_member", "display_name": "Test Maker", + "requested_by": "staff@example.edu", "expires_at": "2026-08-14T15:00:00", + } + result = backend_for(provider).complete_group_link(provider.group_request, "NEWCARD") + assert result.outcome == "group_link_error" + assert "already has an active card" in result.message + assert provider.appended_rows == [] + assert provider.group_updates[-1][1] == "Rejected" diff --git a/Kiosk-v2/docs/kiosk-releases.md b/Kiosk-v2/docs/kiosk-releases.md index 0e4a15b..5338412 100644 --- a/Kiosk-v2/docs/kiosk-releases.md +++ b/Kiosk-v2/docs/kiosk-releases.md @@ -9,6 +9,7 @@ more than one revision is released on the same day. | Revision | Date | What changed | | --- | --- | --- | +| 2026.08.14.12 | August 14, 2026 | Staff can open a newest-first queue of registered, waiver-verified members without active cards, select a member, and have the kiosk prompt that person by name to tap. The first card is connected and the visit is checked in together, with expiry, cancellation, revalidation, and an audit trail. | | 2026.08.14.11 | August 14, 2026 | Graduate students now choose one concise graduate-program answer. Applied Ocean Science records SIO, ECE, or MAE in that answer, and common interdisciplinary programs are represented without adding another screen. | | 2026.08.14.10 | August 14, 2026 | Master's and doctoral students are recorded as separate roles; the profile grid no longer shows a dark empty cell; staff can edit a member's role, affiliation, and graduation details from the Staff Desk. | | 2026.08.14.9 | August 14, 2026 | Profile dialogs are smaller, previous-question navigation consistently says Back, and UG Student Employee is consolidated into Undergraduate Student (UG) on the kiosk and registration form. | diff --git a/Kiosk-v2/lib/kiosk-release.ts b/Kiosk-v2/lib/kiosk-release.ts index 40ee06c..9582634 100644 --- a/Kiosk-v2/lib/kiosk-release.ts +++ b/Kiosk-v2/lib/kiosk-release.ts @@ -1,5 +1,5 @@ export const KIOSK_RELEASE = { - revision: "2026.08.14.11", + revision: "2026.08.14.12", date: "August 14, 2026", - summary: "Analytics-ready graduate programs with clear AOS home departments", + summary: "Staff Desk group onboarding with named kiosk card prompts", } as const; From 5da601d7dd453b8b3326f44971085f994e5453a4 Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Mon, 17 Aug 2026 08:00:06 -0700 Subject: [PATCH 21/26] Accept production card sheet layout --- Kiosk-v2/apps-script-staff/Code.gs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kiosk-v2/apps-script-staff/Code.gs b/Kiosk-v2/apps-script-staff/Code.gs index 9b22d05..fa21e3a 100644 --- a/Kiosk-v2/apps-script-staff/Code.gs +++ b/Kiosk-v2/apps-script-staff/Code.gs @@ -5,7 +5,7 @@ const STAFF_CONFIG_ = { identifiers: { name: "Identifiers", headers: ["Identifier ID", "Person ID", "Type", "Value", "Normalized Value", "Primary", "Verified", "Active", "Created At", "Source System", "Source Rows"] }, certifications: { name: "Tool Certifications", headers: ["Certification ID", "Person ID", "Tool Key", "Status", "Granted At", "Removed At", "Source System", "Source Rows", "Notes"] }, registrations: { name: "Registrations", headers: ["Registration ID", "Person ID", "Status", "Submitted At", "Reviewed By", "Reviewed At", "Program / Department", "Identifier Type", "DocuSign Status", "Consent Version", "Anticipated Graduation", "Source"], allowAdditionalHeaders: true }, - cards: { name: "Cards", headers: ["Card ID", "Person ID", "Card Digest", "Last Four", "Status", "Linked At", "Disabled At", "Source", "Notes"], allowAdditionalHeaders: true }, + cards: { name: "Cards", headers: ["Person ID", "Status"], allowAdditionalHeaders: true }, visits: { name: "Visits", headers: ["Visit ID", "Person ID", "Check In At", "Event Type", "Authorizing Entity", "Flags", "Notes", "Source System", "Source Row"] }, staffAccess: { name: "Staff Access", headers: ["Staff ID", "Name", "Email", "Role", "Active", "Card Linking Allowed", "Notes"] }, training: { name: "Tool Training", headers: ["Training ID", "Person ID", "Tool", "Status", "Approved By", "Approved At", "FabMan Status", "Notes"] }, From 9a7c433a313230db5662928f5298c03ffbe2a190 Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Mon, 17 Aug 2026 08:16:28 -0700 Subject: [PATCH 22/26] Show card connection blockers to staff --- Kiosk-v2/apps-script-staff/Code.gs | 39 +++++++++++++++++---------- Kiosk-v2/apps-script-staff/Index.html | 12 +++++++-- Kiosk-v2/apps-script-staff/README.md | 2 +- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/Kiosk-v2/apps-script-staff/Code.gs b/Kiosk-v2/apps-script-staff/Code.gs index fa21e3a..17187d6 100644 --- a/Kiosk-v2/apps-script-staff/Code.gs +++ b/Kiosk-v2/apps-script-staff/Code.gs @@ -72,27 +72,38 @@ function staffGroupOnboarding() { const personId = String(record["Person ID"] || "").trim(); if (!identifierByPerson[personId] && staffTrue_(record.Active) && String(record.Type || "").toLowerCase() !== "email") identifierByPerson[personId] = record; }); - const candidates = staffRecords_(db.people).filter(function (person) { - const personId = String(person["Person ID"] || "").trim(); - const registration = registrationByPerson[personId]; - const waiverStatus = registration ? staffClean_(registration["DocuSign Status"], 120).toLowerCase() : ""; - return String(person.Status || "").trim().toLowerCase() === "active" - && Boolean(registration) - && /(signed|complete|completed|matched|verified|approved)/.test(waiverStatus) - && !activeCards[personId]; - }).map(function (person) { + const candidates = []; + const blocked = []; + staffRecords_(db.people).forEach(function (person) { const personId = String(person["Person ID"] || "").trim(); + if (String(person.Status || "").trim().toLowerCase() !== "active" || activeCards[personId]) return; const registration = registrationByPerson[personId]; const identifier = identifierByPerson[personId] || {}; - return { + const waiverStatusDisplay = registration ? staffClean_(registration["DocuSign Status"], 120) : ""; + const waiverStatus = waiverStatusDisplay.toLowerCase(); + const record = { personId: personId, name: staffPrivateName_(person["Display Name"]), role: staffClean_(person.Role, 80), - affiliation: staffClean_(registration["Program / Department"], 120), + affiliation: registration ? staffClean_(registration["Program / Department"], 120) : "", identifierHint: staffIdentifierHint_(identifier.Value), - submittedAt: staffIsoDate_(registration["Submitted At"]), + submittedAt: registration ? staffIsoDate_(registration["Submitted At"]) : "", + accountCreatedAt: staffIsoDate_(person["Created At"]), }; - }).sort(function (a, b) { return String(b.submittedAt).localeCompare(String(a.submittedAt)); }); + if (registration && /(signed|complete|completed|matched|verified|approved)/.test(waiverStatus)) { + candidates.push(record); + return; + } + record.blockers = !registration + ? ["Registration not found — ask them to complete the online registration."] + : !waiverStatusDisplay + ? ["Signed waiver not found — DocuSign can take up to 15 minutes to sync."] + : ["Waiver is not verified yet (" + waiverStatusDisplay + ") — allow up to 15 minutes after signing."]; + blocked.push(record); + }); + const onboardingTime_ = function (record) { return String(record.submittedAt || record.accountCreatedAt || ""); }; + candidates.sort(function (a, b) { return onboardingTime_(b).localeCompare(onboardingTime_(a)); }); + blocked.sort(function (a, b) { return onboardingTime_(b).localeCompare(onboardingTime_(a)); }); const requests = staffRecords_(db.kioskLinks); let pending = null; for (let index = requests.length - 1; index >= 0; index -= 1) { @@ -106,7 +117,7 @@ function staffGroupOnboarding() { }; break; } - return { ok: true, actor: access, candidates: candidates, pending: pending, refreshedAt: now.toISOString() }; + return { ok: true, actor: access, candidates: candidates, blocked: blocked, pending: pending, refreshedAt: now.toISOString() }; } function staffStartCardConnection(personId) { diff --git a/Kiosk-v2/apps-script-staff/Index.html b/Kiosk-v2/apps-script-staff/Index.html index 42068ee..b4f5340 100644 --- a/Kiosk-v2/apps-script-staff/Index.html +++ b/Kiosk-v2/apps-script-staff/Index.html @@ -102,6 +102,12 @@ .onboarding-person strong { display:block; color:var(--navy); font-family:"Jost",sans-serif; font-size:21px; } .onboarding-person > span:last-child { color:var(--navy); font-size:24px; } .onboarding-status { color:var(--orange); font-weight:700; } + .onboarding-divider { margin:32px 0 12px; padding-top:24px; border-top:2px solid rgba(15,48,87,.22); } + .onboarding-divider h2 { margin:0 0 5px; } + .onboarding-blocked { display:grid; gap:8px; } + .onboarding-blocked-person { padding:15px 18px; border-left:5px solid var(--orange); background:rgba(245,240,225,.72); } + .onboarding-blocked-person strong { display:block; color:var(--navy); font-family:"Jost",sans-serif; font-size:19px; } + .onboarding-blocker { display:block; margin-top:7px; color:#8a3f22; font-weight:700; } @media (max-width:720px) { header { min-height:62px; } nav { position:fixed; top:auto; bottom:0; left:0; right:0; display:grid; grid-template-columns:repeat(4,1fr); padding:7px; } @@ -134,6 +140,8 @@

Connect cards

Ready accounts are registered, waiver-verified, and have no active card. Newest registrations appear first.

Loading ready accounts
Checking registration, waiver, and card status…
+

Not ready to connect

These accounts have no active card, but registration or waiver information is missing. The reason is shown so staff can explain the next step.

+
Checking accounts that need attention…

Staff notes

Shift handoffs and operational reminders. Avoid sensitive visitor details.

@@ -176,8 +184,8 @@ function personRow(person,left){const method=person.checkInMethod?` · ${esc(person.checkInMethod)}`:"";return `
${esc(person.role)} · Checked in ${time(person.checkedInAt)}${method}
${tags(person)}
`;} function render(){const d=state.data; if(!d)return;el("headcount").textContent=d.present.length;el("bigCount").textContent=d.present.length;el("present").innerHTML=d.present.length?d.present.map(p=>personRow(p,false)).join(""):'
No one is currently marked inside.
';el("left").innerHTML=d.left.length?d.left.map(p=>personRow(p,true)).join(""):'
No departures recorded today.
';el("leftWrap").classList.toggle("hidden",!state.showLeft);const flags=d.present.filter(p=>p.flags.length);el("attention").innerHTML=flags.length?flags.map(p=>`
${esc(p.flags.join(" · "))}

${esc(p.role)}
`).join(""):'
Nothing needs attention.
';renderNotes();el("refreshStatus").textContent=`Updated ${time(d.refreshedAt)}`;} function renderNotes(){if(!state.data)return;const openNotes=state.data.notes.filter(n=>String(n.status).toLowerCase()!=="resolved");const notes=state.data.notes.filter(n=>state.showArchive||String(n.status).toLowerCase()!=="resolved");el("notesList").innerHTML=notes.length?notes.map(n=>`

${esc(n.note)}

${esc(n.createdBy)} · ${time(n.createdAt)}
`).join(""):'
No open staff notes.
';el("toggleArchive").textContent=state.showArchive?'Hide resolved notes':'Show resolved notes';el("noteBell").classList.toggle("has-notes",openNotes.length>0);el("noteCount").classList.toggle("hidden",openNotes.length===0);el("noteCount").textContent=openNotes.length;} - function renderOnboarding(){const data=state.onboarding;if(!data)return;const pending=data.pending;el("onboardingPending").classList.toggle("hidden",!pending);el("onboardingPending").innerHTML=pending?`
WAITING AT THE KIOSK

${esc(pending.name)} should tap their card now. This request expires automatically.

`:"";el("onboardingList").innerHTML=data.candidates.length?data.candidates.map(person=>``).join(""):'
No waiver-verified accounts are waiting for their first card.
';} - async function refreshOnboarding(silent){if(state.onboardingLoading)return;state.onboardingLoading=true;try{if(!silent)el("onboardingList").innerHTML='
Loading ready accounts
Checking registration, waiver, and card status…
';state.onboarding=await call("staffGroupOnboarding");renderOnboarding();}catch(e){fail(e);el("onboardingList").innerHTML='
The ready-account list could not be loaded. Try Refresh list.
';}finally{state.onboardingLoading=false;}} + function renderOnboarding(){const data=state.onboarding;if(!data)return;const pending=data.pending;const blocked=data.blocked||[];el("onboardingPending").classList.toggle("hidden",!pending);el("onboardingPending").innerHTML=pending?`
WAITING AT THE KIOSK

${esc(pending.name)} should tap their card now. This request expires automatically.

`:"";el("onboardingList").innerHTML=data.candidates.length?data.candidates.map(person=>``).join(""):'
No waiver-verified accounts are waiting for their first card.
';el("onboardingBlocked").innerHTML=blocked.length?blocked.map(person=>`
${esc(person.name)}${esc([person.role,person.affiliation,person.identifierHint].filter(Boolean).join(" · "))}${(person.blockers||[]).map(reason=>`${esc(reason)}`).join("")}
`).join(""):'
No cardless accounts are currently missing registration or waiver information.
';} + async function refreshOnboarding(silent){if(state.onboardingLoading)return;state.onboardingLoading=true;try{if(!silent){el("onboardingList").innerHTML='
Loading ready accounts
Checking registration, waiver, and card status…
';el("onboardingBlocked").innerHTML='
Checking accounts that need attention…
';}state.onboarding=await call("staffGroupOnboarding");renderOnboarding();}catch(e){fail(e);el("onboardingList").innerHTML='
The ready-account list could not be loaded. Try Refresh list.
';el("onboardingBlocked").innerHTML='
The not-ready list could not be loaded. Try Refresh list.
';}finally{state.onboardingLoading=false;}} function invalidateRefreshes(){state.mutationGeneration+=1;state.refreshRequest+=1;} async function refresh(silent){const request=++state.refreshRequest;const generation=state.mutationGeneration;try{if(!silent)el("refreshStatus").textContent="Checking for updates…";const data=await call("staffDashboard");if(request!==state.refreshRequest||generation!==state.mutationGeneration)return;state.data=data;render();}catch(e){if(request!==state.refreshRequest||generation!==state.mutationGeneration)return;fail(e);el("refreshStatus").textContent="Couldn’t update — retrying automatically";}} function showSearchResults(target,results){target.innerHTML=results.length?results.map(p=>``).join(""):'
No matches.
';} diff --git a/Kiosk-v2/apps-script-staff/README.md b/Kiosk-v2/apps-script-staff/README.md index 0d6c65a..1018654 100644 --- a/Kiosk-v2/apps-script-staff/README.md +++ b/Kiosk-v2/apps-script-staff/README.md @@ -15,6 +15,6 @@ The app also enforces the active `Staff Access` allowlist on every server call. Any approved staff member can edit a member's profile from the person card. The update writes the role to `People` and the role-dependent affiliation and anticipated graduation to the latest `Registrations` row, with the staff reviewer and timestamp recorded. -Staff whose `Staff Access` row has `Card Linking Allowed` enabled (and administrators) also see **Connect cards**. It lists the newest registered, waiver-verified accounts with no active card. Selecting a person creates a 45-second `Kiosk Link Requests` handoff; the kiosk revalidates the request, connects the first card, records the authorizing staff account, and checks the member in. Existing-card accounts are deliberately excluded and must use the replacement-card workflow. +Staff whose `Staff Access` row has `Card Linking Allowed` enabled (and administrators) also see **Connect cards**. It lists the newest registered, waiver-verified accounts with no active card. A second **Not ready to connect** section shows active cardless accounts that are missing registration or a verified waiver, with a staff-facing explanation of the blocker. Selecting an eligible person creates a 45-second `Kiosk Link Requests` handoff; the kiosk revalidates the request, connects the first card, records the authorizing staff account, and checks the member in. Existing-card accounts are deliberately excluded and must use the replacement-card workflow. FabMan synchronization is deliberately not claimed by this MVP. A recorded approval displays `Not connected` until credentials and resource mapping are configured. From 949267348fa956d0714e1da8ca2645eded1569c8 Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Mon, 17 Aug 2026 11:27:42 -0700 Subject: [PATCH 23/26] Accept legacy or Scripps DocuSign waivers --- Kiosk-v2/apps-script-staff/Code.gs | 63 ++++++++++++++++++-- Kiosk-v2/apps-script-staff/README.md | 2 + Kiosk-v2/bridge/README.md | 2 + Kiosk-v2/bridge/sheets_backend.py | 47 +++++++++++++-- Kiosk-v2/bridge/tests/test_sheets_backend.py | 26 ++++++++ Kiosk-v2/deploy/pi/scanner.env.example | 2 + Kiosk-v2/docs/docusign-transition.md | 60 +++++++++++++++++++ 7 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 Kiosk-v2/docs/docusign-transition.md diff --git a/Kiosk-v2/apps-script-staff/Code.gs b/Kiosk-v2/apps-script-staff/Code.gs index 17187d6..b043a01 100644 --- a/Kiosk-v2/apps-script-staff/Code.gs +++ b/Kiosk-v2/apps-script-staff/Code.gs @@ -1,5 +1,7 @@ const STAFF_CONFIG_ = { spreadsheetProperty: "USER_DATABASE_SPREADSHEET_ID", + scrippsWaiverStatusUrlProperty: "SCRIPPS_WAIVER_STATUS_URL", + scrippsWaiverApiKeyProperty: "SCRIPPS_WAIVER_API_KEY", sheets: { people: { name: "People", headers: ["Person ID", "Status", "Display Name", "Role", "Primary Email", "Secondary Emails", "Created At", "Updated At", "Source System", "Source Rows"] }, identifiers: { name: "Identifiers", headers: ["Identifier ID", "Person ID", "Type", "Value", "Normalized Value", "Primary", "Verified", "Active", "Created At", "Source System", "Source Rows"] }, @@ -68,13 +70,31 @@ function staffGroupOnboarding() { if (String(record.Status || "").trim().toLowerCase() === "active") activeCards[String(record["Person ID"] || "").trim()] = true; }); const identifierByPerson = {}; + const identifiersByPerson = {}; staffRecords_(db.identifiers).forEach(function (record) { const personId = String(record["Person ID"] || "").trim(); - if (!identifierByPerson[personId] && staffTrue_(record.Active) && String(record.Type || "").toLowerCase() !== "email") identifierByPerson[personId] = record; + if (!staffTrue_(record.Active) || String(record.Type || "").toLowerCase() === "email") return; + if (!identifierByPerson[personId]) identifierByPerson[personId] = record; + if (!identifiersByPerson[personId]) identifiersByPerson[personId] = []; + const normalized = staffClean_(record["Normalized Value"] || record.Value, 80); + if (normalized && identifiersByPerson[personId].indexOf(normalized) === -1) identifiersByPerson[personId].push(normalized); }); + const people = staffRecords_(db.people); + const scrippsWaiverByPerson = staffScrippsWaiverMatches_(people.filter(function (person) { + const personId = String(person["Person ID"] || "").trim(); + return String(person.Status || "").trim().toLowerCase() === "active" && !activeCards[personId]; + }).map(function (person) { + const personId = String(person["Person ID"] || "").trim(); + return { + requestId: personId, + identifiers: identifiersByPerson[personId] || [], + email: staffClean_(person["Primary Email"], 254).toLowerCase(), + name: staffClean_(person["Display Name"], 160), + }; + })); const candidates = []; const blocked = []; - staffRecords_(db.people).forEach(function (person) { + people.forEach(function (person) { const personId = String(person["Person ID"] || "").trim(); if (String(person.Status || "").trim().toLowerCase() !== "active" || activeCards[personId]) return; const registration = registrationByPerson[personId]; @@ -90,7 +110,7 @@ function staffGroupOnboarding() { submittedAt: registration ? staffIsoDate_(registration["Submitted At"]) : "", accountCreatedAt: staffIsoDate_(person["Created At"]), }; - if (registration && /(signed|complete|completed|matched|verified|approved)/.test(waiverStatus)) { + if (registration && (/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus) || scrippsWaiverByPerson[personId])) { candidates.push(record); return; } @@ -132,7 +152,16 @@ function staffStartCardConnection(personId) { const registrations = staffRecords_(db.registrations).filter(function (record) { return record["Person ID"] === personId; }); const registration = registrations.length ? registrations[registrations.length - 1] : null; const waiverStatus = registration ? staffClean_(registration["DocuSign Status"], 120).toLowerCase() : ""; - if (!registration || !/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus)) throw new Error("This account is not ready: registration and a verified waiver are required."); + const identifiers = staffRecords_(db.identifiers).filter(function (record) { + return record["Person ID"] === personId && staffTrue_(record.Active) && String(record.Type || "").toLowerCase() !== "email"; + }).map(function (record) { return staffClean_(record["Normalized Value"] || record.Value, 80); }).filter(Boolean); + const scrippsMatch = staffScrippsWaiverMatches_([{ + requestId: personId, + identifiers: identifiers, + email: staffClean_(person["Primary Email"], 254).toLowerCase(), + name: staffClean_(person["Display Name"], 160), + }])[personId]; + if (!registration || (!/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus) && !scrippsMatch)) throw new Error("This account is not ready: registration and a verified waiver are required."); staffCancelPendingKioskLinks_(db.kioskLinks, "Replaced by a newer staff request"); const requestedAt = new Date(); const expiresAt = new Date(requestedAt.getTime() + 45 * 1000); @@ -142,6 +171,32 @@ function staffStartCardConnection(personId) { return { ok: true, requestId: requestId, personId: personId, name: staffPrivateName_(person["Display Name"]), expiresAt: expiresAt.toISOString() }; } +function staffScrippsWaiverMatches_(queries) { + const matches = {}; + if (!queries || !queries.length) return matches; + const properties = PropertiesService.getScriptProperties(); + const url = String(properties.getProperty(STAFF_CONFIG_.scrippsWaiverStatusUrlProperty) || "").trim(); + const apiKey = String(properties.getProperty(STAFF_CONFIG_.scrippsWaiverApiKeyProperty) || "").trim(); + if (!url || !apiKey) return matches; + try { + const response = UrlFetchApp.fetch(url, { + method: "post", + contentType: "application/json", + headers: { Authorization: "Bearer " + apiKey }, + payload: JSON.stringify({ queries: queries }), + muteHttpExceptions: true, + }); + if (response.getResponseCode() !== 200) return matches; + const parsed = JSON.parse(response.getContentText()); + (parsed.matches || []).forEach(function (result) { + if (result && result.matched && result.requestId) matches[String(result.requestId)] = true; + }); + } catch (error) { + console.warn("Scripps waiver lookup unavailable: " + String(error && error.message || error)); + } + return matches; +} + function staffCancelCardConnection(requestId) { staffRequireAccess_(); const sheet = staffDatabase_().kioskLinks; diff --git a/Kiosk-v2/apps-script-staff/README.md b/Kiosk-v2/apps-script-staff/README.md index 1018654..4b81c4c 100644 --- a/Kiosk-v2/apps-script-staff/README.md +++ b/Kiosk-v2/apps-script-staff/README.md @@ -11,6 +11,8 @@ Responsive staff-only web app backed by the same normalized Google spreadsheet a 5. Run `setupStaffApp()` once to create `Tool Training` and `Staff Notes`. 6. Deploy as a web app executing as the deploying account, restricted to UC San Diego users. +During the DocuSign transition, optionally set `SCRIPPS_WAIVER_STATUS_URL` and `SCRIPPS_WAIVER_API_KEY` as Script Properties. The staff card-connection queue will then accept either the existing registration waiver status or a verified completion from the Scripps DocuSign Web Form. If the new service is unavailable, legacy verified waivers remain sufficient and the queue fails closed for new-only records. + The app also enforces the active `Staff Access` allowlist on every server call. Roles are `staff`, `trainer`, and `administrator`; only trainers and administrators can record laser training. Any approved staff member can edit a member's profile from the person card. The update writes the role to `People` and the role-dependent affiliation and anticipated graduation to the latest `Registrations` row, with the staff reviewer and timestamp recorded. diff --git a/Kiosk-v2/bridge/README.md b/Kiosk-v2/bridge/README.md index b577bce..5f5a8ba 100644 --- a/Kiosk-v2/bridge/README.md +++ b/Kiosk-v2/bridge/README.md @@ -14,6 +14,8 @@ When the kiosk UI is served from `localhost`, it automatically connects to `ws:/ The Sheets backend warms its user, waiver, and activity caches at startup. The user and waiver cache defaults to five minutes; the activity cache defaults to one hour and is updated after every successful append. Override these with `SHEETS_CACHE_SECONDS` and `SHEETS_ACTIVITY_CACHE_SECONDS` when needed. +Waiver acceptance is additive during the Scripps transition. The existing `Waiver Signatures SIO` sheet is checked first and remains sufficient indefinitely. When `SCRIPPS_WAIVER_STATUS_URL` and `SCRIPPS_WAIVER_API_KEY_FILE` are configured, a miss in the legacy sheet is followed by a lookup against completed Scripps DocuSign Web Form records. A failure in the new service never invalidates a legacy match. + ## Designated-staff card linking When an unrecognized member card is scanned, the bridge keeps its UID in memory for five minutes. A staff member verifies the member's physical ID, enters the matching PID, TSN, or employee ID in the kiosk, and approves the link by tapping their own already-linked card. The member's UID is never sent to the browser. Successful links update `Card UUID` in the user database and append a `Card Linked` audit row to the activity sheet. diff --git a/Kiosk-v2/bridge/sheets_backend.py b/Kiosk-v2/bridge/sheets_backend.py index d93ca12..3e306ea 100644 --- a/Kiosk-v2/bridge/sheets_backend.py +++ b/Kiosk-v2/bridge/sheets_backend.py @@ -17,6 +17,8 @@ from threading import Lock import time from typing import Any, Callable, Protocol +import json +from urllib.request import Request, urlopen from uuid import uuid4 @@ -126,6 +128,8 @@ def __init__( card_hmac_secret: str, cache_seconds: int = 300, activity_cache_seconds: int = 3600, + scripps_waiver_status_url: str = "", + scripps_waiver_api_key: str = "", ) -> None: self.credentials_path = credentials_path self.database_id = database_id @@ -133,6 +137,8 @@ def __init__( self.card_hmac_secret = card_hmac_secret self.cache_seconds = cache_seconds self.activity_cache_seconds = activity_cache_seconds + self.scripps_waiver_status_url = scripps_waiver_status_url + self.scripps_waiver_api_key = scripps_waiver_api_key self._lock = Lock() self._people_sheet: Any = None self._database: Any = None @@ -153,6 +159,8 @@ def from_environment(cls) -> "GoogleSheetsProvider": database_id = os.getenv("SHEETS_DATABASE_ID", "").strip() if not credentials_path or not database_id: raise RuntimeError("SHEETS_CREDENTIALS_PATH and SHEETS_DATABASE_ID are required") + api_key_file = os.getenv("SCRIPPS_WAIVER_API_KEY_FILE", "").strip() + api_key = Path(api_key_file).read_text(encoding="utf-8").strip() if api_key_file else "" return cls( credentials_path=credentials_path, database_id=database_id, @@ -160,8 +168,36 @@ def from_environment(cls) -> "GoogleSheetsProvider": card_hmac_secret=required_secret(), cache_seconds=int(os.getenv("SHEETS_CACHE_SECONDS", "300")), activity_cache_seconds=int(os.getenv("SHEETS_ACTIVITY_CACHE_SECONDS", "3600")), + scripps_waiver_status_url=os.getenv("SCRIPPS_WAIVER_STATUS_URL", "").strip(), + scripps_waiver_api_key=api_key, ) + def additional_waiver_found(self, user: dict[str, Any]) -> bool: + if not self.scripps_waiver_status_url or not self.scripps_waiver_api_key: + return False + payload = json.dumps({ + "identifiers": sorted(normalized_user_identifiers(user)), + "email": normalize_email(user.get("Email Address")), + "name": str(user.get("Name", "")).strip(), + }).encode("utf-8") + request = Request( + self.scripps_waiver_status_url, + data=payload, + headers={ + "Authorization": "Bearer " + self.scripps_waiver_api_key, + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=8) as response: + result = json.loads(response.read().decode("utf-8")) + except Exception: + LOGGER.exception("Scripps waiver status lookup failed") + return False + matches = result.get("matches") if isinstance(result, dict) else None + return bool(isinstance(matches, list) and matches and matches[0].get("matched")) + def card_digest(self, card_uid: str) -> str: return hmac.new( self.card_hmac_secret.encode("utf-8"), @@ -626,18 +662,19 @@ def link_card(self, identifier: str, card_uid: str, staff_card_uid: str, designa def _waiver_found(self, user: dict[str, Any]) -> bool: user_ids = normalized_user_identifiers(user) user_email = normalize_email(user.get("Email Address")) - return any( + legacy_found = any( (normalize_person_id(waiver.get("A_Number")) in user_ids) or (bool(user_email) and normalize_email(waiver.get("Email")) == user_email) for waiver in self.provider.waiver_records() ) + if legacy_found: + return True + additional_checker = getattr(self.provider, "additional_waiver_found", None) + return bool(additional_checker and additional_checker(user)) def _check_in_user(self, user: dict[str, Any], total_started: float, timings: dict[str, int]) -> CheckInResult: - user_ids = normalized_user_identifiers(user) - user_email = normalize_email(user.get("Email Address")) stage_started = time.monotonic() - waivers = self.provider.waiver_records() - waiver_found = any((normalize_person_id(waiver.get("A_Number")) in user_ids) or (bool(user_email) and normalize_email(waiver.get("Email")) == user_email) for waiver in waivers) + waiver_found = self._waiver_found(user) timings["waiver_lookup"] = elapsed_ms(stage_started) if not waiver_found: timings["total"] = elapsed_ms(total_started) diff --git a/Kiosk-v2/bridge/tests/test_sheets_backend.py b/Kiosk-v2/bridge/tests/test_sheets_backend.py index 3c7fee5..19d9ae9 100644 --- a/Kiosk-v2/bridge/tests/test_sheets_backend.py +++ b/Kiosk-v2/bridge/tests/test_sheets_backend.py @@ -21,6 +21,7 @@ def __init__(self, users=None, waivers=None, existing_activity=None): self.calls = {"users": 0, "waivers": 0, "activity": 0, "append": 0, "card_update": 0} self.group_request = None self.group_updates = [] + self.additional_waiver = False @staticmethod def card_digest(card_uid): @@ -34,6 +35,10 @@ def waiver_records(self): self.calls["waivers"] += 1 return [dict(row) for row in self.waivers] + def additional_waiver_found(self, user): + del user + return self.additional_waiver + def activity_rows(self): self.calls["activity"] += 1 return [list(row) for row in self.existing_activity] @@ -273,6 +278,27 @@ def test_known_card_without_waiver_does_not_read_activity_or_write(): assert provider.calls["append"] == 0 +def test_new_scripps_waiver_is_accepted_when_legacy_sheet_has_no_match(): + provider = FakeProvider(users=[member()], waivers=[]) + provider.additional_waiver = True + result = backend_for(provider).check_in("CARD123") + assert result.outcome == "success" + assert provider.calls["append"] == 1 + + +def test_legacy_waiver_still_succeeds_without_calling_new_source(): + provider = FakeProvider(users=[member()], waivers=[signed_waiver()]) + provider.additional_waiver = True + calls = {"new": 0} + def new_source(user): + del user + calls["new"] += 1 + return True + provider.additional_waiver_found = new_source + assert backend_for(provider).check_in("CARD123").outcome == "success" + assert calls["new"] == 0 + + def test_email_waiver_match_is_case_insensitive(): provider = FakeProvider( users=[member(student_id="", email="Maker@Example.com")], diff --git a/Kiosk-v2/deploy/pi/scanner.env.example b/Kiosk-v2/deploy/pi/scanner.env.example index 26d6042..237b096 100644 --- a/Kiosk-v2/deploy/pi/scanner.env.example +++ b/Kiosk-v2/deploy/pi/scanner.env.example @@ -5,6 +5,8 @@ SHEETS_CREDENTIALS_PATH=/home/sandbox/.config/sandbox-kiosk/google-service-accou SHEETS_USER_DATABASE_URL=REPLACE_WITH_PRODUCTION_DATABASE_URL SHEETS_WAIVER_URL=REPLACE_WITH_WAIVER_SPREADSHEET_URL SHEETS_ACTIVITY_URL=REPLACE_WITH_PRODUCTION_DATABASE_URL +SCRIPPS_WAIVER_STATUS_URL=https://REPLACE_WITH_SITE_HOST/api/status +SCRIPPS_WAIVER_API_KEY_FILE=/home/sandbox/.config/sandbox-kiosk/scripps-waiver-api-key CARD_UID_HMAC_SECRET=REPLACE_WITH_SECRET_FROM_CREDENTIAL_REGISTER CARD_LINK_STAFF_IDS=REPLACE_WITH_COMMA_SEPARATED_APPROVED_IDS CARD_LINK_SESSION_SECONDS=300 diff --git a/Kiosk-v2/docs/docusign-transition.md b/Kiosk-v2/docs/docusign-transition.md new file mode 100644 index 0000000..ede7e36 --- /dev/null +++ b/Kiosk-v2/docs/docusign-transition.md @@ -0,0 +1,60 @@ +# Scripps DocuSign waiver transition + +## Acceptance policy + +The transition is additive. A completed waiver from either source is sufficient: + +1. the existing `Waiver Signatures SIO` Google Sheet; or +2. the Scripps-owned DocuSign Web Form sync service. + +The kiosk checks the legacy sheet first. Legacy completions do not expire merely +because the Scripps form is introduced, and the new service never writes to or +invalidates the old sheet. If the new service is unavailable, legacy matches +continue to work and new-only records fail closed until service returns. + +## Data flow + +DocuSign Connect sends a completed-envelope event to the dedicated public sync +service. The service verifies DocuSign's HMAC signature and stores only the +minimum matching record: source envelope ID, status, participant name and email, +optional UC San Diego identifier, signed date, receipt date, and a payload hash. +It does not expose a participant browser. + +The Raspberry Pi and Staff Desk app call an API-key-protected status endpoint. +Matching uses an exact normalized UC San Diego identifier first. When an +identifier was not supplied, it accepts a unique exact email-and-name match. An +ambiguous fallback does not verify the waiver. + +## Required production configuration + +- Public sync URL: `https://scripps-sandbox-waiver-sync.rjatplay.chatgpt.site` +- DocuSign Connect webhook: `/api/docusign` +- Kiosk and Staff Desk lookup: `/api/status` +- Sync-service secret: `DOCUSIGN_CONNECT_HMAC_SECRET` +- Shared kiosk/Staff Desk secret: `WAIVER_STATUS_API_KEY` +- Raspberry Pi settings: `SCRIPPS_WAIVER_STATUS_URL` and + `SCRIPPS_WAIVER_API_KEY_FILE` +- Staff Apps Script properties: `SCRIPPS_WAIVER_STATUS_URL` and + `SCRIPPS_WAIVER_API_KEY` + +Do not switch the registration or kiosk waiver link until the public service, +DocuSign Connect event delivery, and at least one end-to-end completion test all +succeed. + +## DocuSign administrator request + +Ask the DocuSign administrator to enable Connect for completed envelope events +from the Sandbox Web Form/template, deliver JSON including recipient and tab +data, use the webhook URL above, and enable HMAC signing with the shared secret. +If account-wide Connect is inappropriate, request the narrowest available +configuration scoped to the Sandbox template or integration user. + +## Acceptance test + +Verify all three cases before changing the public waiver link: + +1. A legacy-only signer checks in successfully. +2. A new Scripps-form signer checks in successfully after the Connect event. +3. A person with neither waiver sees the waiver-required flow. + +Also stop the new service temporarily and confirm case 1 still succeeds. From 339610f413260df8c44c20c798f3720b5ed78e2d Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Mon, 17 Aug 2026 11:44:45 -0700 Subject: [PATCH 24/26] Record dual-waiver kiosk release --- Kiosk-v2/docs/kiosk-releases.md | 1 + Kiosk-v2/lib/kiosk-release.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Kiosk-v2/docs/kiosk-releases.md b/Kiosk-v2/docs/kiosk-releases.md index 5338412..23f7bcc 100644 --- a/Kiosk-v2/docs/kiosk-releases.md +++ b/Kiosk-v2/docs/kiosk-releases.md @@ -9,6 +9,7 @@ more than one revision is released on the same day. | Revision | Date | What changed | | --- | --- | --- | +| 2026.08.17.1 | August 17, 2026 | Existing legacy waivers remain sufficient while completed waivers from the new Scripps-owned DocuSign form are accepted as a second source. New-source failures cannot invalidate a legacy match. | | 2026.08.14.12 | August 14, 2026 | Staff can open a newest-first queue of registered, waiver-verified members without active cards, select a member, and have the kiosk prompt that person by name to tap. The first card is connected and the visit is checked in together, with expiry, cancellation, revalidation, and an audit trail. | | 2026.08.14.11 | August 14, 2026 | Graduate students now choose one concise graduate-program answer. Applied Ocean Science records SIO, ECE, or MAE in that answer, and common interdisciplinary programs are represented without adding another screen. | | 2026.08.14.10 | August 14, 2026 | Master's and doctoral students are recorded as separate roles; the profile grid no longer shows a dark empty cell; staff can edit a member's role, affiliation, and graduation details from the Staff Desk. | diff --git a/Kiosk-v2/lib/kiosk-release.ts b/Kiosk-v2/lib/kiosk-release.ts index 9582634..ec400c9 100644 --- a/Kiosk-v2/lib/kiosk-release.ts +++ b/Kiosk-v2/lib/kiosk-release.ts @@ -1,5 +1,5 @@ export const KIOSK_RELEASE = { - revision: "2026.08.14.12", - date: "August 14, 2026", - summary: "Staff Desk group onboarding with named kiosk card prompts", + revision: "2026.08.17.1", + date: "August 17, 2026", + summary: "Legacy and Scripps DocuSign waivers accepted together", } as const; From 52cabdcfa6a3abb4c1979da05b9984d104825a70 Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Mon, 17 Aug 2026 13:48:39 -0700 Subject: [PATCH 25/26] Move waiver status to Google Sheets --- Kiosk-v2/apps-script-registration/Code.gs | 158 ++++++++++++++++++ Kiosk-v2/apps-script-registration/README.md | 8 +- .../test/submission.test.cjs | 38 +++++ Kiosk-v2/apps-script-staff/Code.gs | 28 +--- Kiosk-v2/apps-script-staff/README.md | 2 +- Kiosk-v2/apps-script-staff/StaffCore.gs | 28 ++++ .../test/staff-core.test.cjs | 13 ++ Kiosk-v2/bridge/README.md | 2 +- Kiosk-v2/bridge/sheets_backend.py | 53 +++--- Kiosk-v2/bridge/tests/test_sheets_backend.py | 14 ++ Kiosk-v2/deploy/pi/scanner.env.example | 3 +- Kiosk-v2/docs/docusign-transition.md | 85 +++++----- Kiosk-v2/docs/kiosk-releases.md | 1 + Kiosk-v2/lib/kiosk-release.ts | 4 +- 14 files changed, 330 insertions(+), 107 deletions(-) diff --git a/Kiosk-v2/apps-script-registration/Code.gs b/Kiosk-v2/apps-script-registration/Code.gs index 5014807..f615956 100644 --- a/Kiosk-v2/apps-script-registration/Code.gs +++ b/Kiosk-v2/apps-script-registration/Code.gs @@ -1,9 +1,12 @@ const REGISTRATION_CONFIG_ = { spreadsheetProperty: "USER_DATABASE_SPREADSHEET_ID", waiverUrlProperty: "WAIVER_POWERFORM_URL", + docusignConnectTokenProperty: "DOCUSIGN_CONNECT_TOKEN", + docusignTemplateIdProperty: "DOCUSIGN_WAIVER_TEMPLATE_ID", peopleSheet: "People", identifiersSheet: "Identifiers", registrationsSheet: "Registrations", + scrippsWaiversSheet: "Scripps Waivers", consentVersion: "2026-08-11", }; @@ -13,6 +16,161 @@ function doGet(event) { return template.evaluate().setTitle("Create a Scripps Sandbox account"); } +function doPost(event) { + try { + const expectedToken = getRequiredScriptProperty_(REGISTRATION_CONFIG_.docusignConnectTokenProperty); + const suppliedToken = String(event && event.parameter && event.parameter.waiver_key || ""); + if (!constantTimeEqual_(suppliedToken, expectedToken)) throw new Error("Unauthorized webhook request."); + const payload = JSON.parse(String(event && event.postData && event.postData.contents || "{}")); + const waiver = extractCompletedDocuSignWaiver_(payload); + if (!waiver) return jsonOutput_({ ok: true, stored: false, reason: "Event was not a completed waiver." }); + const expectedTemplateId = String(PropertiesService.getScriptProperties().getProperty(REGISTRATION_CONFIG_.docusignTemplateIdProperty) || "").trim(); + if (expectedTemplateId && waiver.templateId && waiver.templateId !== expectedTemplateId) throw new Error("Unexpected DocuSign template."); + + const lock = LockService.getScriptLock(); + lock.waitLock(20000); + try { + const spreadsheet = openRegistrationSpreadsheet_(); + const sheet = requireOrCreateScrippsWaiversSheet_(spreadsheet); + const headers = getHeaders_(sheet); + const envelopeColumn = headers.indexOf("Envelope ID") + 1; + const existing = sheet.getLastRow() > 1 + ? sheet.getRange(2, envelopeColumn, sheet.getLastRow() - 1, 1).getDisplayValues().some(function (row) { return row[0] === waiver.envelopeId; }) + : false; + if (!existing) appendNamedRow_(sheet, { + "Received At": new Date(), + "Envelope ID": waiver.envelopeId, + "Status": "completed", + "Completed At": waiver.completedAt, + "Participant Name": waiver.participantName, + "Participant Email": waiver.participantEmail, + "Participant ID": waiver.participantId, + "Normalized Identifier": waiver.normalizedIdentifier, + "Template ID": waiver.templateId, + "Source": "DocuSign Connect", + }); + SpreadsheetApp.flush(); + return jsonOutput_({ ok: true, stored: !existing, duplicate: existing }); + } finally { + lock.releaseLock(); + } + } catch (error) { + console.error("DocuSign webhook rejected: " + String(error && error.message || error)); + return jsonOutput_({ ok: false, error: "Webhook rejected." }); + } +} + +function jsonOutput_(value) { + return ContentService.createTextOutput(JSON.stringify(value)).setMimeType(ContentService.MimeType.JSON); +} + +function constantTimeEqual_(left, right) { + const a = String(left || ""); + const b = String(right || ""); + let difference = a.length ^ b.length; + const width = Math.max(a.length, b.length); + for (let index = 0; index < width; index += 1) difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0); + return difference === 0; +} + +function docuSignFirstScalar_(value, keys) { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const found = docuSignFirstScalar_(value[index], keys); + if (found) return found; + } + return ""; + } + if (!value || typeof value !== "object") return ""; + const names = Object.keys(value); + for (let index = 0; index < names.length; index += 1) { + const key = names[index]; + const child = value[key]; + if (keys.indexOf(key.toLowerCase()) !== -1 && (typeof child === "string" || typeof child === "number")) { + const text = String(child).trim(); + if (text) return text; + } + } + for (let index = 0; index < names.length; index += 1) { + const found = docuSignFirstScalar_(value[names[index]], keys); + if (found) return found; + } + return ""; +} + +function docuSignLabel_(value) { + return String(value || "").normalize("NFKC").trim().toLowerCase().replace(/[^a-z0-9_]+/g, ""); +} + +function docuSignFieldValues_(value, output) { + const values = output || {}; + if (Array.isArray(value)) { + value.forEach(function (item) { docuSignFieldValues_(item, values); }); + return values; + } + if (!value || typeof value !== "object") return values; + const fieldLabel = docuSignLabel_(value.tabLabel || value.fieldName || value.name || value.apiName || value.originalValue); + const raw = value.value != null ? value.value : (value.text != null ? value.text : (value.selected != null ? value.selected : value.formattedValue)); + if (fieldLabel && (typeof raw === "string" || typeof raw === "number")) { + const text = String(raw).trim(); + if (text && !values[fieldLabel]) values[fieldLabel] = text; + } + Object.keys(value).forEach(function (key) { docuSignFieldValues_(value[key], values); }); + return values; +} + +function docuSignValueFor_(values, labels) { + for (let index = 0; index < labels.length; index += 1) { + const value = values[docuSignLabel_(labels[index])]; + if (value) return value; + } + return ""; +} + +function normalizeWaiverIdentifier_(value) { + const normalized = String(value || "").normalize("NFKC").toUpperCase().replace(/[^A-Z0-9]/g, ""); + if (/^A\d{8}$/.test(normalized)) return normalized.slice(1); + if (/^0+\d+$/.test(normalized)) return normalized.replace(/^0+(?=\d)/, ""); + return normalized; +} + +function extractCompletedDocuSignWaiver_(payload) { + const eventName = docuSignFirstScalar_(payload, ["event", "eventtype", "event_type"]).toLowerCase(); + const status = docuSignFirstScalar_(payload, ["status", "envelopestatus"]).toLowerCase(); + if (eventName.indexOf("completed") === -1 && status !== "completed") return null; + const envelopeId = docuSignFirstScalar_(payload, ["envelopeid", "envelope_id"]); + if (!envelopeId) throw new Error("Completed event is missing an envelope ID."); + const values = docuSignFieldValues_(payload); + const participantName = docuSignValueFor_(values, ["participantname", "participant_name", "fullname", "name"]) + || docuSignFirstScalar_(payload, ["recipientname", "fullname"]); + const participantEmail = docuSignValueFor_(values, ["participantemail", "participant_email", "email"]) + || docuSignFirstScalar_(payload, ["recipientemail", "email"]); + if (!participantName || !participantEmail) throw new Error("Completed event is missing participant name or email."); + const participantId = docuSignValueFor_(values, ["ucsdid", "ucsd_id", "ucsandiegoid", "a_number", "anumber"]); + return { + envelopeId: envelopeId, + templateId: docuSignFirstScalar_(payload, ["templateid", "template_id"]), + completedAt: docuSignValueFor_(values, ["datesigned", "date_signed", "completeddatetime", "completed_at"]) + || docuSignFirstScalar_(payload, ["completeddatetime", "completed_at", "datesigned", "sentdatetime"]) + || new Date().toISOString(), + participantName: participantName, + participantEmail: participantEmail.trim().toLowerCase(), + participantId: participantId, + normalizedIdentifier: normalizeWaiverIdentifier_(participantId), + }; +} + +function requireOrCreateScrippsWaiversSheet_(spreadsheet) { + let sheet = spreadsheet.getSheetByName(REGISTRATION_CONFIG_.scrippsWaiversSheet); + if (!sheet) { + sheet = spreadsheet.insertSheet(REGISTRATION_CONFIG_.scrippsWaiversSheet); + sheet.appendRow(["Received At", "Envelope ID", "Status", "Completed At", "Participant Name", "Participant Email", "Participant ID", "Normalized Identifier", "Template ID", "Source"]); + sheet.setFrozenRows(1); + } + assertHeaders_(sheet, ["Received At", "Envelope ID", "Status", "Completed At", "Participant Name", "Participant Email", "Participant ID", "Normalized Identifier", "Template ID", "Source"]); + return sheet; +} + function setupRegistrationSheet() { const spreadsheet = openRegistrationSpreadsheet_(); const people = requireSheet_(spreadsheet, REGISTRATION_CONFIG_.peopleSheet); diff --git a/Kiosk-v2/apps-script-registration/README.md b/Kiosk-v2/apps-script-registration/README.md index b0daef5..62e642c 100644 --- a/Kiosk-v2/apps-script-registration/README.md +++ b/Kiosk-v2/apps-script-registration/README.md @@ -24,7 +24,7 @@ The kiosk still requires a matching waiver record before check-in succeeds. Afte 1. Create a standalone Apps Script project in the UCSD-managed account. 2. Add `RegistrationCore.gs`, `Code.gs`, `Index.html`, and the manifest. -3. In **Project Settings → Script properties**, add `USER_DATABASE_SPREADSHEET_ID` with the target user-database spreadsheet ID and `WAIVER_POWERFORM_URL` with the approved DocuSign PowerForm URL. Neither value belongs in source control. +3. In **Project Settings → Script properties**, add `USER_DATABASE_SPREADSHEET_ID` with the target user-database spreadsheet ID and `WAIVER_POWERFORM_URL` with the approved DocuSign PowerForm URL. For the Scripps-owned waiver callback, also add a long random `DOCUSIGN_CONNECT_TOKEN` and the exact `DOCUSIGN_WAIVER_TEMPLATE_ID`. None of these values belongs in source control. 4. Confirm the normalized tables include the columns asserted by `setupRegistrationSheet`, including `Registrations` → `Anticipated Graduation`, then run the function as the owner. 5. Run `registrationStatus` and confirm the returned spreadsheet and tab names. 6. Deploy as a web app that executes as the deploying UCSD account. If UCSD policy permits, allow anonymous access; otherwise stop and use the allowed domain setting rather than moving the app to a personal account. @@ -33,6 +33,12 @@ The kiosk still requires a matching waiver record before check-in succeeds. Afte 9. Set `NEXT_PUBLIC_REGISTRATION_URL` to the deployed Apps Script web-app URL, rebuild the kiosk, and verify its QR code and link on a phone. 10. Only then replace the public website's registration link. +The same web-app deployment accepts completed DocuSign Connect JSON at +`WEB_APP_URL?waiver_key=DOCUSIGN_CONNECT_TOKEN`. It writes duplicate-safe rows +to the `Scripps Waivers` tab in the production spreadsheet. Restrict DocuSign +delivery to completed events for the Sandbox template and never publish the +callback URL. + For a non-production permission and schema test, set `USER_DATABASE_SPREADSHEET_ID` to an approved test-copy ID, run `setupRegistrationSheet`, and submit only fictional records. Replace the property with the production ID only after the test succeeds. ## Local validation diff --git a/Kiosk-v2/apps-script-registration/test/submission.test.cjs b/Kiosk-v2/apps-script-registration/test/submission.test.cjs index 6bd5026..aa2819c 100644 --- a/Kiosk-v2/apps-script-registration/test/submission.test.cjs +++ b/Kiosk-v2/apps-script-registration/test/submission.test.cjs @@ -130,3 +130,41 @@ test("does not append when an active email already exists", () => { assert.equal(result.ok, false); assert.equal(harness.people.appended.length, 0); }); + +test("extracts a completed DocuSign waiver and normalizes the UCSD ID", () => { + const harness = makeHarness(); + harness.context.webhookPayload = { + event: "envelope-completed", + data: { + envelopeId: "envelope-123", + templateId: "template-456", + envelopeSummary: { + status: "completed", + recipients: { + signers: [{ + tabs: { + textTabs: [ + { tabLabel: "participant_name", value: "Ada Lovelace" }, + { tabLabel: "participant_email", value: "ADA@UCSD.EDU" }, + { tabLabel: "ucsd_id", value: "A12345678" }, + ], + }, + }], + }, + }, + }, + }; + const result = vm.runInContext("extractCompletedDocuSignWaiver_(webhookPayload)", harness.context); + assert.equal(result.envelopeId, "envelope-123"); + assert.equal(result.templateId, "template-456"); + assert.equal(result.participantName, "Ada Lovelace"); + assert.equal(result.participantEmail, "ada@ucsd.edu"); + assert.equal(result.normalizedIdentifier, "12345678"); +}); + +test("ignores DocuSign events that are not completed", () => { + const harness = makeHarness(); + harness.context.webhookPayload = { event: "envelope-sent", data: { envelopeId: "envelope-123", status: "sent" } }; + const result = vm.runInContext("extractCompletedDocuSignWaiver_(webhookPayload)", harness.context); + assert.equal(result, null); +}); diff --git a/Kiosk-v2/apps-script-staff/Code.gs b/Kiosk-v2/apps-script-staff/Code.gs index b043a01..2e7daf3 100644 --- a/Kiosk-v2/apps-script-staff/Code.gs +++ b/Kiosk-v2/apps-script-staff/Code.gs @@ -1,7 +1,5 @@ const STAFF_CONFIG_ = { spreadsheetProperty: "USER_DATABASE_SPREADSHEET_ID", - scrippsWaiverStatusUrlProperty: "SCRIPPS_WAIVER_STATUS_URL", - scrippsWaiverApiKeyProperty: "SCRIPPS_WAIVER_API_KEY", sheets: { people: { name: "People", headers: ["Person ID", "Status", "Display Name", "Role", "Primary Email", "Secondary Emails", "Created At", "Updated At", "Source System", "Source Rows"] }, identifiers: { name: "Identifiers", headers: ["Identifier ID", "Person ID", "Type", "Value", "Normalized Value", "Primary", "Verified", "Active", "Created At", "Source System", "Source Rows"] }, @@ -14,6 +12,7 @@ const STAFF_CONFIG_ = { fabmanLinks: { name: "FabMan Links", headers: ["Link ID", "Person ID", "FabMan Member ID", "Status", "Match Method", "Confirmed By", "Confirmed At", "Notes"] }, notes: { name: "Staff Notes", headers: ["Note ID", "Note", "Created By", "Created At", "Status", "Resolved By", "Resolved At"] }, kioskLinks: { name: "Kiosk Link Requests", headers: ["Request ID", "Person ID", "Display Name", "Requested By", "Requested At", "Expires At", "Status", "Completed At", "Message"], createIfMissing: true }, + scrippsWaivers: { name: "Scripps Waivers", headers: ["Received At", "Envelope ID", "Status", "Completed At", "Participant Name", "Participant Email", "Participant ID", "Normalized Identifier", "Template ID", "Source"], createIfMissing: true }, }, }; @@ -172,29 +171,8 @@ function staffStartCardConnection(personId) { } function staffScrippsWaiverMatches_(queries) { - const matches = {}; - if (!queries || !queries.length) return matches; - const properties = PropertiesService.getScriptProperties(); - const url = String(properties.getProperty(STAFF_CONFIG_.scrippsWaiverStatusUrlProperty) || "").trim(); - const apiKey = String(properties.getProperty(STAFF_CONFIG_.scrippsWaiverApiKeyProperty) || "").trim(); - if (!url || !apiKey) return matches; - try { - const response = UrlFetchApp.fetch(url, { - method: "post", - contentType: "application/json", - headers: { Authorization: "Bearer " + apiKey }, - payload: JSON.stringify({ queries: queries }), - muteHttpExceptions: true, - }); - if (response.getResponseCode() !== 200) return matches; - const parsed = JSON.parse(response.getContentText()); - (parsed.matches || []).forEach(function (result) { - if (result && result.matched && result.requestId) matches[String(result.requestId)] = true; - }); - } catch (error) { - console.warn("Scripps waiver lookup unavailable: " + String(error && error.message || error)); - } - return matches; + if (!queries || !queries.length) return {}; + return staffScrippsWaiverMatchesFromRecords_(queries, staffRecords_(staffDatabase_().scrippsWaivers)); } function staffCancelCardConnection(requestId) { diff --git a/Kiosk-v2/apps-script-staff/README.md b/Kiosk-v2/apps-script-staff/README.md index 4b81c4c..a861ba9 100644 --- a/Kiosk-v2/apps-script-staff/README.md +++ b/Kiosk-v2/apps-script-staff/README.md @@ -11,7 +11,7 @@ Responsive staff-only web app backed by the same normalized Google spreadsheet a 5. Run `setupStaffApp()` once to create `Tool Training` and `Staff Notes`. 6. Deploy as a web app executing as the deploying account, restricted to UC San Diego users. -During the DocuSign transition, optionally set `SCRIPPS_WAIVER_STATUS_URL` and `SCRIPPS_WAIVER_API_KEY` as Script Properties. The staff card-connection queue will then accept either the existing registration waiver status or a verified completion from the Scripps DocuSign Web Form. If the new service is unavailable, legacy verified waivers remain sufficient and the queue fails closed for new-only records. +During the DocuSign transition, the staff card-connection queue accepts either the existing registration waiver status or a completed record in the production spreadsheet's `Scripps Waivers` tab. `setupStaffApp()` creates that tab when needed. No external waiver-status service or additional Staff Desk secret is required. The app also enforces the active `Staff Access` allowlist on every server call. Roles are `staff`, `trainer`, and `administrator`; only trainers and administrators can record laser training. diff --git a/Kiosk-v2/apps-script-staff/StaffCore.gs b/Kiosk-v2/apps-script-staff/StaffCore.gs index 406e72a..f5301fe 100644 --- a/Kiosk-v2/apps-script-staff/StaffCore.gs +++ b/Kiosk-v2/apps-script-staff/StaffCore.gs @@ -64,6 +64,32 @@ function staffIdentifierHint_(value) { return cleaned.length >= 4 ? "ID ending " + cleaned.slice(-4) : ""; } +function staffNormalizeWaiverIdentifier_(value) { + const normalized = staffClean_(value, 120).toUpperCase().replace(/[^A-Z0-9]/g, ""); + if (/^A\d{8}$/.test(normalized)) return normalized.slice(1); + if (/^0+\d+$/.test(normalized)) return normalized.replace(/^0+(?=\d)/, ""); + return normalized; +} + +function staffScrippsWaiverMatchesFromRecords_(queries, records) { + const matches = {}; + const completed = (records || []).filter(function (record) { + return staffClean_(record.Status, 40).toLowerCase() === "completed"; + }); + (queries || []).forEach(function (query) { + const identifiers = (query.identifiers || []).map(staffNormalizeWaiverIdentifier_).filter(Boolean); + const email = staffClean_(query.email, 254).toLowerCase(); + const matched = completed.some(function (record) { + const recordIdentifier = staffNormalizeWaiverIdentifier_(record["Normalized Identifier"] || record["Participant ID"]); + const recordEmail = staffClean_(record["Participant Email"], 254).toLowerCase(); + return (Boolean(recordIdentifier) && identifiers.indexOf(recordIdentifier) !== -1) + || (Boolean(email) && recordEmail === email); + }); + if (matched && query.requestId) matches[String(query.requestId)] = true; + }); + return matches; +} + function staffToolLabel_(toolKey) { const cleaned = staffClean_(toolKey, 80).toLowerCase(); if (cleaned === "epilog_laser_cutter") return "Laser cutter"; @@ -166,6 +192,8 @@ if (typeof module !== "undefined") module.exports = { staffPreferredName_: staffPreferredName_, staffPrivateName_: staffPrivateName_, staffIdentifierHint_: staffIdentifierHint_, + staffNormalizeWaiverIdentifier_: staffNormalizeWaiverIdentifier_, + staffScrippsWaiverMatchesFromRecords_: staffScrippsWaiverMatchesFromRecords_, staffToolLabel_: staffToolLabel_, staffRoleLabel_: staffRoleLabel_, staffAttentionFlags_: staffAttentionFlags_, diff --git a/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs b/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs index d076529..bd25aa1 100644 --- a/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs +++ b/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs @@ -23,6 +23,19 @@ test("identifier hints reveal only the final four characters", () => { assert.equal(core.staffIdentifierHint_("23"), ""); }); +test("new Scripps waivers match completed records by normalized ID or email", () => { + const records = [ + { Status: "completed", "Participant Email": "member@ucsd.edu", "Participant ID": "A12345678", "Normalized Identifier": "12345678" }, + { Status: "voided", "Participant Email": "voided@ucsd.edu", "Participant ID": "A87654321", "Normalized Identifier": "87654321" }, + ]; + const matches = core.staffScrippsWaiverMatchesFromRecords_([ + { requestId: "by-id", identifiers: ["A12345678"], email: "" }, + { requestId: "by-email", identifiers: [], email: "MEMBER@UCSD.EDU" }, + { requestId: "voided", identifiers: ["A87654321"], email: "voided@ucsd.edu" }, + ], records); + assert.deepEqual({ ...matches }, { "by-id": true, "by-email": true }); +}); + test("legacy tool keys become readable approval labels", () => { assert.equal(core.staffToolLabel_("epilog_laser_cutter"), "Laser cutter"); assert.equal(core.staffToolLabel_("wood_shop"), "Wood Shop"); diff --git a/Kiosk-v2/bridge/README.md b/Kiosk-v2/bridge/README.md index 5f5a8ba..38a536c 100644 --- a/Kiosk-v2/bridge/README.md +++ b/Kiosk-v2/bridge/README.md @@ -14,7 +14,7 @@ When the kiosk UI is served from `localhost`, it automatically connects to `ws:/ The Sheets backend warms its user, waiver, and activity caches at startup. The user and waiver cache defaults to five minutes; the activity cache defaults to one hour and is updated after every successful append. Override these with `SHEETS_CACHE_SECONDS` and `SHEETS_ACTIVITY_CACHE_SECONDS` when needed. -Waiver acceptance is additive during the Scripps transition. The existing `Waiver Signatures SIO` sheet is checked first and remains sufficient indefinitely. When `SCRIPPS_WAIVER_STATUS_URL` and `SCRIPPS_WAIVER_API_KEY_FILE` are configured, a miss in the legacy sheet is followed by a lookup against completed Scripps DocuSign Web Form records. A failure in the new service never invalidates a legacy match. +Waiver acceptance is additive during the Scripps transition. The existing `Waiver Signatures SIO` sheet is checked first and remains sufficient indefinitely. A miss in the legacy sheet is followed by a local lookup in the production database's `Scripps Waivers` tab. The tab name defaults to `Scripps Waivers` and can be overridden with `SCRIPPS_WAIVER_TAB_NAME`; if the tab is absent, legacy checks continue normally and new-only records fail closed. ## Designated-staff card linking diff --git a/Kiosk-v2/bridge/sheets_backend.py b/Kiosk-v2/bridge/sheets_backend.py index 3e306ea..b9352bc 100644 --- a/Kiosk-v2/bridge/sheets_backend.py +++ b/Kiosk-v2/bridge/sheets_backend.py @@ -17,8 +17,6 @@ from threading import Lock import time from typing import Any, Callable, Protocol -import json -from urllib.request import Request, urlopen from uuid import uuid4 @@ -128,8 +126,7 @@ def __init__( card_hmac_secret: str, cache_seconds: int = 300, activity_cache_seconds: int = 3600, - scripps_waiver_status_url: str = "", - scripps_waiver_api_key: str = "", + scripps_waiver_tab_name: str = "Scripps Waivers", ) -> None: self.credentials_path = credentials_path self.database_id = database_id @@ -137,8 +134,7 @@ def __init__( self.card_hmac_secret = card_hmac_secret self.cache_seconds = cache_seconds self.activity_cache_seconds = activity_cache_seconds - self.scripps_waiver_status_url = scripps_waiver_status_url - self.scripps_waiver_api_key = scripps_waiver_api_key + self.scripps_waiver_tab_name = scripps_waiver_tab_name self._lock = Lock() self._people_sheet: Any = None self._database: Any = None @@ -147,8 +143,10 @@ def __init__( self._registrations_sheet: Any = None self._visits_sheet: Any = None self._waiver_sheet: Any = None + self._scripps_waiver_sheet: Any = None self._users: list[dict[str, Any]] | None = None self._waivers: list[dict[str, Any]] | None = None + self._scripps_waivers: list[dict[str, Any]] | None = None self._activity_rows: list[list[Any]] | None = None self._cache_expires_at = 0.0 self._activity_cache_expires_at = 0.0 @@ -159,8 +157,6 @@ def from_environment(cls) -> "GoogleSheetsProvider": database_id = os.getenv("SHEETS_DATABASE_ID", "").strip() if not credentials_path or not database_id: raise RuntimeError("SHEETS_CREDENTIALS_PATH and SHEETS_DATABASE_ID are required") - api_key_file = os.getenv("SCRIPPS_WAIVER_API_KEY_FILE", "").strip() - api_key = Path(api_key_file).read_text(encoding="utf-8").strip() if api_key_file else "" return cls( credentials_path=credentials_path, database_id=database_id, @@ -168,35 +164,21 @@ def from_environment(cls) -> "GoogleSheetsProvider": card_hmac_secret=required_secret(), cache_seconds=int(os.getenv("SHEETS_CACHE_SECONDS", "300")), activity_cache_seconds=int(os.getenv("SHEETS_ACTIVITY_CACHE_SECONDS", "3600")), - scripps_waiver_status_url=os.getenv("SCRIPPS_WAIVER_STATUS_URL", "").strip(), - scripps_waiver_api_key=api_key, + scripps_waiver_tab_name=os.getenv("SCRIPPS_WAIVER_TAB_NAME", "Scripps Waivers").strip(), ) def additional_waiver_found(self, user: dict[str, Any]) -> bool: - if not self.scripps_waiver_status_url or not self.scripps_waiver_api_key: - return False - payload = json.dumps({ - "identifiers": sorted(normalized_user_identifiers(user)), - "email": normalize_email(user.get("Email Address")), - "name": str(user.get("Name", "")).strip(), - }).encode("utf-8") - request = Request( - self.scripps_waiver_status_url, - data=payload, - headers={ - "Authorization": "Bearer " + self.scripps_waiver_api_key, - "Content-Type": "application/json", - }, - method="POST", + self._refresh_people_if_needed() + user_ids = normalized_user_identifiers(user) + user_email = normalize_email(user.get("Email Address")) + return any( + str(record.get("Status", "")).strip().lower() == "completed" + and ( + normalize_person_id(record.get("Normalized Identifier") or record.get("Participant ID")) in user_ids + or (bool(user_email) and normalize_email(record.get("Participant Email")) == user_email) + ) + for record in self._scripps_waivers or [] ) - try: - with urlopen(request, timeout=8) as response: - result = json.loads(response.read().decode("utf-8")) - except Exception: - LOGGER.exception("Scripps waiver status lookup failed") - return False - matches = result.get("matches") if isinstance(result, dict) else None - return bool(isinstance(matches, list) and matches and matches[0].get("matched")) def card_digest(self, card_uid: str) -> str: return hmac.new( @@ -219,6 +201,10 @@ def _connect(self) -> None: self._registrations_sheet = database.worksheet("Registrations") self._visits_sheet = database.worksheet("Visits") self._waiver_sheet = client.open(self.waiver_sheet_name).sheet1 + try: + self._scripps_waiver_sheet = database.worksheet(self.scripps_waiver_tab_name) + except gspread.WorksheetNotFound: + self._scripps_waiver_sheet = None def _refresh_people_if_needed(self) -> None: now = time.monotonic() @@ -230,6 +216,7 @@ def _refresh_people_if_needed(self) -> None: cards = self._cards_sheet.get_all_records(numericise_ignore=["all"]) registrations = self._registrations_sheet.get_all_records(numericise_ignore=["all"]) self._waivers = self._waiver_sheet.get_all_records(numericise_ignore=["all"]) + self._scripps_waivers = self._scripps_waiver_sheet.get_all_records(numericise_ignore=["all"]) if self._scripps_waiver_sheet else [] identifiers_by_person: dict[str, list[dict[str, Any]]] = {} cards_by_person: dict[str, list[dict[str, Any]]] = {} for record in identifiers: diff --git a/Kiosk-v2/bridge/tests/test_sheets_backend.py b/Kiosk-v2/bridge/tests/test_sheets_backend.py index 19d9ae9..0b82813 100644 --- a/Kiosk-v2/bridge/tests/test_sheets_backend.py +++ b/Kiosk-v2/bridge/tests/test_sheets_backend.py @@ -1,4 +1,5 @@ from datetime import datetime +import time from sheets_backend import ( GoogleSheetsProvider, @@ -9,6 +10,19 @@ ) +def test_google_provider_matches_completed_scripps_waiver_from_production_tab(): + provider = GoogleSheetsProvider("unused.json", "database-id", "Waiver Signatures SIO", "secret") + provider._users = [] + provider._cache_expires_at = time.monotonic() + 60 + provider._scripps_waivers = [ + {"Status": "completed", "Participant Email": "member@ucsd.edu", "Participant ID": "A12345678", "Normalized Identifier": "12345678"}, + {"Status": "voided", "Participant Email": "voided@ucsd.edu", "Participant ID": "A87654321", "Normalized Identifier": "87654321"}, + ] + assert provider.additional_waiver_found({"Identifiers": ["A12345678"], "Email Address": ""}) + assert provider.additional_waiver_found({"Identifiers": [], "Email Address": "MEMBER@UCSD.EDU"}) + assert not provider.additional_waiver_found({"Identifiers": ["A87654321"], "Email Address": "voided@ucsd.edu"}) + + class FakeProvider: def __init__(self, users=None, waivers=None, existing_activity=None): self.users = [dict(row) for row in (users or [])] diff --git a/Kiosk-v2/deploy/pi/scanner.env.example b/Kiosk-v2/deploy/pi/scanner.env.example index 237b096..37acbfa 100644 --- a/Kiosk-v2/deploy/pi/scanner.env.example +++ b/Kiosk-v2/deploy/pi/scanner.env.example @@ -5,8 +5,7 @@ SHEETS_CREDENTIALS_PATH=/home/sandbox/.config/sandbox-kiosk/google-service-accou SHEETS_USER_DATABASE_URL=REPLACE_WITH_PRODUCTION_DATABASE_URL SHEETS_WAIVER_URL=REPLACE_WITH_WAIVER_SPREADSHEET_URL SHEETS_ACTIVITY_URL=REPLACE_WITH_PRODUCTION_DATABASE_URL -SCRIPPS_WAIVER_STATUS_URL=https://REPLACE_WITH_SITE_HOST/api/status -SCRIPPS_WAIVER_API_KEY_FILE=/home/sandbox/.config/sandbox-kiosk/scripps-waiver-api-key +SCRIPPS_WAIVER_TAB_NAME=Scripps Waivers CARD_UID_HMAC_SECRET=REPLACE_WITH_SECRET_FROM_CREDENTIAL_REGISTER CARD_LINK_STAFF_IDS=REPLACE_WITH_COMMA_SEPARATED_APPROVED_IDS CARD_LINK_SESSION_SECONDS=300 diff --git a/Kiosk-v2/docs/docusign-transition.md b/Kiosk-v2/docs/docusign-transition.md index ede7e36..e8e94c0 100644 --- a/Kiosk-v2/docs/docusign-transition.md +++ b/Kiosk-v2/docs/docusign-transition.md @@ -5,56 +5,57 @@ The transition is additive. A completed waiver from either source is sufficient: 1. the existing `Waiver Signatures SIO` Google Sheet; or -2. the Scripps-owned DocuSign Web Form sync service. - -The kiosk checks the legacy sheet first. Legacy completions do not expire merely -because the Scripps form is introduced, and the new service never writes to or -invalidates the old sheet. If the new service is unavailable, legacy matches -continue to work and new-only records fail closed until service returns. - -## Data flow - -DocuSign Connect sends a completed-envelope event to the dedicated public sync -service. The service verifies DocuSign's HMAC signature and stores only the -minimum matching record: source envelope ID, status, participant name and email, -optional UC San Diego identifier, signed date, receipt date, and a payload hash. -It does not expose a participant browser. - -The Raspberry Pi and Staff Desk app call an API-key-protected status endpoint. -Matching uses an exact normalized UC San Diego identifier first. When an -identifier was not supplied, it accepts a unique exact email-and-name match. An -ambiguous fallback does not verify the waiver. - -## Required production configuration - -- Public sync URL: `https://scripps-sandbox-waiver-sync.rjatplay.chatgpt.site` -- DocuSign Connect webhook: `/api/docusign` -- Kiosk and Staff Desk lookup: `/api/status` -- Sync-service secret: `DOCUSIGN_CONNECT_HMAC_SECRET` -- Shared kiosk/Staff Desk secret: `WAIVER_STATUS_API_KEY` -- Raspberry Pi settings: `SCRIPPS_WAIVER_STATUS_URL` and - `SCRIPPS_WAIVER_API_KEY_FILE` -- Staff Apps Script properties: `SCRIPPS_WAIVER_STATUS_URL` and - `SCRIPPS_WAIVER_API_KEY` - -Do not switch the registration or kiosk waiver link until the public service, -DocuSign Connect event delivery, and at least one end-to-end completion test all -succeed. +2. the `Scripps Waivers` tab in `Scripps Sandbox Database v2 — Production`. + +Legacy completions remain valid indefinitely. The Scripps integration never +writes to or invalidates the old sheet. + +## Google-only data flow + +DocuSign Connect sends completed-envelope JSON to the UC San Diego-owned +registration Apps Script web app. The handler validates an unguessable callback +token and, when configured, the exact DocuSign template ID. It records only the +minimum matching data in the production spreadsheet: receipt time, envelope ID, +status, completion time, participant name and email, optional UC San Diego ID, +normalized ID, template ID, and source. + +Duplicate Connect deliveries are safe: envelope ID is unique and a repeated +event does not add a second row. The kiosk and Staff Desk read the protected +Google Sheet directly; they do not call a separate public status service. + +## Required configuration + +- Registration Apps Script property: `DOCUSIGN_CONNECT_TOKEN` +- Registration Apps Script property: `DOCUSIGN_WAIVER_TEMPLATE_ID` +- Production spreadsheet tab: `Scripps Waivers` +- Optional Pi setting: `SCRIPPS_WAIVER_TAB_NAME=Scripps Waivers` +- DocuSign Connect destination: + `REGISTRATION_WEB_APP_URL?waiver_key=DOCUSIGN_CONNECT_TOKEN` + +Do not place the callback URL or token in source control, staff documentation, +QR codes, or public pages. + +Google Apps Script exposes query parameters and POST bodies to `doPost`, but it +does not expose arbitrary request headers. Therefore this design uses a strong +callback token plus template filtering rather than DocuSign's HMAC header. If +UC San Diego requires HMAC verification for this workflow, use a UCSD-owned +Google Cloud Function or Cloud Run receiver instead; the Sheet schema and kiosk +lookups do not need to change. ## DocuSign administrator request -Ask the DocuSign administrator to enable Connect for completed envelope events -from the Sandbox Web Form/template, deliver JSON including recipient and tab -data, use the webhook URL above, and enable HMAC signing with the shared secret. -If account-wide Connect is inappropriate, request the narrowest available -configuration scoped to the Sandbox template or integration user. +Ask the DocuSign administrator to send completed-envelope JSON for the Sandbox +Web Form/template to the private callback URL we provide, including recipient +and form-field data. Scope the configuration to the Sandbox template or form +when possible. ## Acceptance test Verify all three cases before changing the public waiver link: 1. A legacy-only signer checks in successfully. -2. A new Scripps-form signer checks in successfully after the Connect event. +2. A new Scripps-form signer checks in successfully after a completed event + creates one row in `Scripps Waivers`. 3. A person with neither waiver sees the waiver-required flow. -Also stop the new service temporarily and confirm case 1 still succeeds. +Also resend the same completed event and confirm it does not create a duplicate. diff --git a/Kiosk-v2/docs/kiosk-releases.md b/Kiosk-v2/docs/kiosk-releases.md index 23f7bcc..da1451a 100644 --- a/Kiosk-v2/docs/kiosk-releases.md +++ b/Kiosk-v2/docs/kiosk-releases.md @@ -9,6 +9,7 @@ more than one revision is released on the same day. | Revision | Date | What changed | | --- | --- | --- | +| 2026.08.17.2 | August 17, 2026 | The Scripps-owned waiver transition now stays in UC San Diego Google services: DocuSign completion events write to the protected production spreadsheet, and both Staff Desk and kiosk read that tab directly. | | 2026.08.17.1 | August 17, 2026 | Existing legacy waivers remain sufficient while completed waivers from the new Scripps-owned DocuSign form are accepted as a second source. New-source failures cannot invalidate a legacy match. | | 2026.08.14.12 | August 14, 2026 | Staff can open a newest-first queue of registered, waiver-verified members without active cards, select a member, and have the kiosk prompt that person by name to tap. The first card is connected and the visit is checked in together, with expiry, cancellation, revalidation, and an audit trail. | | 2026.08.14.11 | August 14, 2026 | Graduate students now choose one concise graduate-program answer. Applied Ocean Science records SIO, ECE, or MAE in that answer, and common interdisciplinary programs are represented without adding another screen. | diff --git a/Kiosk-v2/lib/kiosk-release.ts b/Kiosk-v2/lib/kiosk-release.ts index ec400c9..925caf4 100644 --- a/Kiosk-v2/lib/kiosk-release.ts +++ b/Kiosk-v2/lib/kiosk-release.ts @@ -1,5 +1,5 @@ export const KIOSK_RELEASE = { - revision: "2026.08.17.1", + revision: "2026.08.17.2", date: "August 17, 2026", - summary: "Legacy and Scripps DocuSign waivers accepted together", + summary: "Google-only Scripps waiver records plus legacy acceptance", } as const; From f36a2dd468e18ad89f5ca03ffcc6d93af6ea6f53 Mon Sep 17 00:00:00 2001 From: Scripts Sandbox Date: Mon, 17 Aug 2026 16:41:34 -0700 Subject: [PATCH 26/26] Accept legacy waivers in Staff Desk --- Kiosk-v2/apps-script-staff/Code.gs | 36 ++++++++++++++++--- Kiosk-v2/apps-script-staff/Index.html | 4 ++- Kiosk-v2/apps-script-staff/README.md | 1 + Kiosk-v2/apps-script-staff/StaffCore.gs | 17 +++++++++ .../test/staff-core.test.cjs | 20 +++++++++++ 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/Kiosk-v2/apps-script-staff/Code.gs b/Kiosk-v2/apps-script-staff/Code.gs index 2e7daf3..e1f05f4 100644 --- a/Kiosk-v2/apps-script-staff/Code.gs +++ b/Kiosk-v2/apps-script-staff/Code.gs @@ -1,5 +1,6 @@ const STAFF_CONFIG_ = { spreadsheetProperty: "USER_DATABASE_SPREADSHEET_ID", + legacyWaiverSpreadsheetId: "1KtaxQ13qnXknGVgUpQIKOnPhdSOulPYboHy0GwTtHfY", sheets: { people: { name: "People", headers: ["Person ID", "Status", "Display Name", "Role", "Primary Email", "Secondary Emails", "Created At", "Updated At", "Source System", "Source Rows"] }, identifiers: { name: "Identifiers", headers: ["Identifier ID", "Person ID", "Type", "Value", "Normalized Value", "Primary", "Verified", "Active", "Created At", "Source System", "Source Rows"] }, @@ -17,6 +18,7 @@ const STAFF_CONFIG_ = { }; var STAFF_SPREADSHEET_MEMO_ = null; +var STAFF_LEGACY_WAIVER_SHEET_MEMO_ = null; var STAFF_RECORDS_MEMO_ = {}; const STAFF_CACHE_SECONDS_ = { dashboard: 8, person: 30, search: 30, fabman: 45 }; @@ -79,7 +81,7 @@ function staffGroupOnboarding() { if (normalized && identifiersByPerson[personId].indexOf(normalized) === -1) identifiersByPerson[personId].push(normalized); }); const people = staffRecords_(db.people); - const scrippsWaiverByPerson = staffScrippsWaiverMatches_(people.filter(function (person) { + const waiverByPerson = staffWaiverMatches_(people.filter(function (person) { const personId = String(person["Person ID"] || "").trim(); return String(person.Status || "").trim().toLowerCase() === "active" && !activeCards[personId]; }).map(function (person) { @@ -109,7 +111,7 @@ function staffGroupOnboarding() { submittedAt: registration ? staffIsoDate_(registration["Submitted At"]) : "", accountCreatedAt: staffIsoDate_(person["Created At"]), }; - if (registration && (/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus) || scrippsWaiverByPerson[personId])) { + if (registration && (/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus) || waiverByPerson[personId])) { candidates.push(record); return; } @@ -154,13 +156,13 @@ function staffStartCardConnection(personId) { const identifiers = staffRecords_(db.identifiers).filter(function (record) { return record["Person ID"] === personId && staffTrue_(record.Active) && String(record.Type || "").toLowerCase() !== "email"; }).map(function (record) { return staffClean_(record["Normalized Value"] || record.Value, 80); }).filter(Boolean); - const scrippsMatch = staffScrippsWaiverMatches_([{ + const waiverMatch = staffWaiverMatches_([{ requestId: personId, identifiers: identifiers, email: staffClean_(person["Primary Email"], 254).toLowerCase(), name: staffClean_(person["Display Name"], 160), }])[personId]; - if (!registration || (!/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus) && !scrippsMatch)) throw new Error("This account is not ready: registration and a verified waiver are required."); + if (!registration || (!/(signed|complete|completed|matched|verified|approved)/.test(waiverStatus) && !waiverMatch)) throw new Error("This account is not ready: registration and a verified waiver are required."); staffCancelPendingKioskLinks_(db.kioskLinks, "Replaced by a newer staff request"); const requestedAt = new Date(); const expiresAt = new Date(requestedAt.getTime() + 45 * 1000); @@ -175,6 +177,18 @@ function staffScrippsWaiverMatches_(queries) { return staffScrippsWaiverMatchesFromRecords_(queries, staffRecords_(staffDatabase_().scrippsWaivers)); } +function staffLegacyWaiverMatches_(queries) { + if (!queries || !queries.length) return {}; + return staffLegacyWaiverMatchesFromRecords_(queries, staffLegacyWaiverRecords_()); +} + +function staffWaiverMatches_(queries) { + const matches = staffScrippsWaiverMatches_(queries); + const legacyMatches = staffLegacyWaiverMatches_(queries); + Object.keys(legacyMatches).forEach(function (requestId) { matches[requestId] = true; }); + return matches; +} + function staffCancelCardConnection(requestId) { staffRequireAccess_(); const sheet = staffDatabase_().kioskLinks; @@ -893,6 +907,20 @@ function staffSpreadsheet_() { return STAFF_SPREADSHEET_MEMO_; } +function staffLegacyWaiverSheet_() { + if (STAFF_LEGACY_WAIVER_SHEET_MEMO_) return STAFF_LEGACY_WAIVER_SHEET_MEMO_; + STAFF_LEGACY_WAIVER_SHEET_MEMO_ = SpreadsheetApp.openById(STAFF_CONFIG_.legacyWaiverSpreadsheetId).getSheets()[0]; + return STAFF_LEGACY_WAIVER_SHEET_MEMO_; +} + +function staffLegacyWaiverRecords_() { + const cached = staffCacheGetJson_("legacy-waivers"); + if (cached) return cached; + const records = staffRecords_(staffLegacyWaiverSheet_()); + staffCachePutJson_("legacy-waivers", records, 60); + return records; +} + function staffDatabase_(accessOnly) { const spreadsheet = staffSpreadsheet_(); const keys = accessOnly ? ["staffAccess"] : Object.keys(STAFF_CONFIG_.sheets); diff --git a/Kiosk-v2/apps-script-staff/Index.html b/Kiosk-v2/apps-script-staff/Index.html index b4f5340..3f37ea7 100644 --- a/Kiosk-v2/apps-script-staff/Index.html +++ b/Kiosk-v2/apps-script-staff/Index.html @@ -34,6 +34,8 @@ .btn { padding:10px 13px; border:1px solid var(--navy); background:transparent; color:var(--navy); } .btn.primary { border-color:var(--orange); background:var(--orange); font-weight:700; } .btn.small { padding:7px 9px; } + .btn[aria-busy="true"] { display:inline-flex; align-items:center; gap:7px; cursor:wait; opacity:.82; } + .btn[aria-busy="true"]::before { content:""; width:12px; height:12px; border:2px solid currentColor; border-right-color:transparent; border-radius:50%; animation:spin .7s linear infinite; } .layout { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:18px; } .section-title { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:10px; } .count { color:var(--orange); font-size:34px; } @@ -218,7 +220,7 @@ el("toggleLeft").onclick=()=>{state.showLeft=!state.showLeft;el("toggleLeft").textContent=state.showLeft?"Hide left":"Left today";render();}; el("openManual").onclick=()=>{el("manualDialog").showModal();el("manualSearch").focus();};el("closeManual").onclick=()=>el("manualDialog").close(); el("manualSearch").oninput=()=>queueSearch(el("manualSearch"),el("manualResults"));el("trainingSearch").oninput=()=>queueSearch(el("trainingSearch"),el("trainingResults")); - document.body.onclick=async event=>{const left=event.target.closest("[data-left]");const reopen=event.target.closest("[data-reopen]");const person=event.target.closest("[data-person]");const result=event.target.closest("[data-result-person]");const editProfile=event.target.closest("[data-edit-profile]");const approve=event.target.closest("[data-approve-card]");const findFabman=event.target.closest("[data-find-fabman]");const linkFabman=event.target.closest("[data-link-fabman]");const addPackage=event.target.closest("[data-add-package]");const note=event.target.closest("[data-note]");const connectCard=event.target.closest("[data-connect-card]");const cancelKiosk=event.target.closest("[data-cancel-kiosk]");try{if(connectCard){connectCard.disabled=true;const r=await call("staffStartCardConnection",connectCard.dataset.connectCard);toast(`${r.name}: kiosk is ready for their card`);await refreshOnboarding(true);return;}if(cancelKiosk){await call("staffCancelCardConnection",cancelKiosk.dataset.cancelKiosk);toast("Kiosk card request cancelled");await refreshOnboarding(true);return;}if(left){invalidateRefreshes();const r=await call("staffMarkLeft",left.dataset.left);movePresence(left.dataset.left,true);toast(`${r.name} marked as left`);setTimeout(()=>refresh(true),250);return;}if(reopen){invalidateRefreshes();const r=await call("staffReopen",reopen.dataset.reopen);movePresence(reopen.dataset.reopen,false);toast(`${r.name} returned to Currently here`);setTimeout(()=>refresh(true),250);return;}if(person){await openPersonCard(person.dataset.person);return;}if(editProfile){openProfileEditor();return;}if(result&&result.closest("#manualResults")){invalidateRefreshes();const r=await call("staffManualCheckIn",result.dataset.resultPerson);el("manualDialog").close();toast(`${r.name} checked in`);setTimeout(()=>refresh(true),250);return;}if(result&&result.closest("#trainingResults")){await openPersonCard(result.dataset.resultPerson);return;}if(approve&&state.personCard){el("confirmCopy").textContent=`Confirm that ${state.personCard.name} completed laser-cutter training?`;el("confirmDialog").showModal();return;}if(findFabman&&state.personCard){await findFabmanMember();return;}if(linkFabman&&state.personCard){linkFabman.disabled=true;linkFabman.textContent="Linking…";const personId=state.personCard.personId;const r=await call("staffConfirmFabmanLink",personId,linkFabman.dataset.linkFabman);delete state.personCards[personId];el("fabmanDialog").close();toast(r.sync&&r.sync.label?r.sync.label:"FabMan member linked");await openPersonCard(personId);return;}if(addPackage&&state.personCard){state.pendingFabmanMember={id:addPackage.dataset.addPackage,name:addPackage.dataset.candidateName,hint:addPackage.dataset.candidateId};el("packageCopy").textContent=`Add ${state.pendingFabmanMember.name} (${state.pendingFabmanMember.hint}) to Scripps Sandbox and link this record?`;el("packageDialog").showModal();return;}if(note){await call("staffResolveNote",note.dataset.note,note.dataset.reopenNote==="true");toast(note.dataset.reopenNote==="true"?"Note reopened":"Note archived");refresh(true);}}catch(e){fail(e);if(connectCard)connectCard.disabled=false;}}; + document.body.onclick=async event=>{const left=event.target.closest("[data-left]");const reopen=event.target.closest("[data-reopen]");const person=event.target.closest("[data-person]");const result=event.target.closest("[data-result-person]");const editProfile=event.target.closest("[data-edit-profile]");const approve=event.target.closest("[data-approve-card]");const findFabman=event.target.closest("[data-find-fabman]");const linkFabman=event.target.closest("[data-link-fabman]");const addPackage=event.target.closest("[data-add-package]");const note=event.target.closest("[data-note]");const connectCard=event.target.closest("[data-connect-card]");const cancelKiosk=event.target.closest("[data-cancel-kiosk]");try{if(connectCard){connectCard.disabled=true;const r=await call("staffStartCardConnection",connectCard.dataset.connectCard);toast(`${r.name}: kiosk is ready for their card`);await refreshOnboarding(true);return;}if(cancelKiosk){await call("staffCancelCardConnection",cancelKiosk.dataset.cancelKiosk);toast("Kiosk card request cancelled");await refreshOnboarding(true);return;}if(left){left.disabled=true;left.setAttribute("aria-busy","true");left.textContent="Marking left…";invalidateRefreshes();const r=await call("staffMarkLeft",left.dataset.left);movePresence(left.dataset.left,true);toast(`${r.name} marked as left`);setTimeout(()=>refresh(true),250);return;}if(reopen){invalidateRefreshes();const r=await call("staffReopen",reopen.dataset.reopen);movePresence(reopen.dataset.reopen,false);toast(`${r.name} returned to Currently here`);setTimeout(()=>refresh(true),250);return;}if(person){await openPersonCard(person.dataset.person);return;}if(editProfile){openProfileEditor();return;}if(result&&result.closest("#manualResults")){invalidateRefreshes();const r=await call("staffManualCheckIn",result.dataset.resultPerson);el("manualDialog").close();toast(`${r.name} checked in`);setTimeout(()=>refresh(true),250);return;}if(result&&result.closest("#trainingResults")){await openPersonCard(result.dataset.resultPerson);return;}if(approve&&state.personCard){el("confirmCopy").textContent=`Confirm that ${state.personCard.name} completed laser-cutter training?`;el("confirmDialog").showModal();return;}if(findFabman&&state.personCard){await findFabmanMember();return;}if(linkFabman&&state.personCard){linkFabman.disabled=true;linkFabman.textContent="Linking…";const personId=state.personCard.personId;const r=await call("staffConfirmFabmanLink",personId,linkFabman.dataset.linkFabman);delete state.personCards[personId];el("fabmanDialog").close();toast(r.sync&&r.sync.label?r.sync.label:"FabMan member linked");await openPersonCard(personId);return;}if(addPackage&&state.personCard){state.pendingFabmanMember={id:addPackage.dataset.addPackage,name:addPackage.dataset.candidateName,hint:addPackage.dataset.candidateId};el("packageCopy").textContent=`Add ${state.pendingFabmanMember.name} (${state.pendingFabmanMember.hint}) to Scripps Sandbox and link this record?`;el("packageDialog").showModal();return;}if(note){await call("staffResolveNote",note.dataset.note,note.dataset.reopenNote==="true");toast(note.dataset.reopenNote==="true"?"Note reopened":"Note archived");refresh(true);}}catch(e){fail(e);if(connectCard)connectCard.disabled=false;if(left){left.disabled=false;left.removeAttribute("aria-busy");left.textContent="Mark left";}}}; el("closePerson").onclick=()=>el("personDialog").close(); el("profileRole").onchange=()=>syncProfileFields("");el("profileAffiliation").onchange=()=>syncProfileFields(el("profileAffiliation").value);el("closeProfile").onclick=()=>el("profileDialog").close();el("cancelProfile").onclick=()=>el("profileDialog").close();el("profileForm").onsubmit=async event=>{event.preventDefault();if(!state.personCard)return;const save=el("saveProfile");const choice=el("profileAffiliation").value;const affiliation=profileChoicesNeedingDetail.has(choice)?`${choice} – ${el("profileOther").value.trim()}`:choice;const payload={role:el("profileRole").value,affiliation,anticipatedGraduation:el("profileGraduation").value};el("profileError").classList.add("hidden");save.disabled=true;save.textContent="Saving…";try{const card=await call("staffUpdateProfile",state.personCard.personId,payload);state.personCard=card;state.personCards[card.personId]=card;renderPersonCard(card);el("profileDialog").close();toast("Profile updated");await refresh(true);}catch(error){el("profileError").textContent=error&&error.message?error.message:"The profile could not be saved.";el("profileError").classList.remove("hidden");}finally{save.disabled=false;save.textContent="Save profile";}}; el("closeConfirm").onclick=()=>el("confirmDialog").close();el("closeFabman").onclick=()=>el("fabmanDialog").close();el("closePackage").onclick=()=>el("packageDialog").close();el("confirmPackage").onclick=async()=>{try{if(!state.pendingFabmanMember)return;const personId=state.personCard.personId;el("confirmPackage").disabled=true;el("confirmPackage").textContent="Adding…";const r=await call("staffAddSandboxPackageAndLink",personId,state.pendingFabmanMember.id);delete state.personCards[personId];el("packageDialog").close();el("fabmanDialog").close();toast(r.sync&&r.sync.label?r.sync.label:"Added to Scripps Sandbox");await openPersonCard(personId);}catch(e){fail(e);}finally{el("confirmPackage").disabled=false;el("confirmPackage").textContent="Add package and link";}};el("approveLaser").onclick=async()=>{try{const personId=state.personCard.personId;const r=await call("staffApproveLaser",personId);delete state.personCards[personId];el("confirmDialog").close();toast(`${r.name} approved · ${r.fabmanStatus}`);await openPersonCard(personId);}catch(e){fail(e);}}; diff --git a/Kiosk-v2/apps-script-staff/README.md b/Kiosk-v2/apps-script-staff/README.md index a861ba9..3785ef8 100644 --- a/Kiosk-v2/apps-script-staff/README.md +++ b/Kiosk-v2/apps-script-staff/README.md @@ -7,6 +7,7 @@ Responsive staff-only web app backed by the same normalized Google spreadsheet a 1. Create a standalone Apps Script project owned by the UCSD Sandbox account. 2. Copy `appsscript.json`, `StaffCore.gs`, `Code.gs`, and `Index.html` into it. 3. Set script property `USER_DATABASE_SPREADSHEET_ID` to the normalized database ID. +4. The Staff Desk accepts an exact ID or email match from the existing `Waiver Signatures SIO` spreadsheet as well as completed records in the production `Scripps Waivers` tab. 4. In the `Staff Access` tab, make the deploying account active with role `administrator`. 5. Run `setupStaffApp()` once to create `Tool Training` and `Staff Notes`. 6. Deploy as a web app executing as the deploying account, restricted to UC San Diego users. diff --git a/Kiosk-v2/apps-script-staff/StaffCore.gs b/Kiosk-v2/apps-script-staff/StaffCore.gs index f5301fe..df0756c 100644 --- a/Kiosk-v2/apps-script-staff/StaffCore.gs +++ b/Kiosk-v2/apps-script-staff/StaffCore.gs @@ -90,6 +90,22 @@ function staffScrippsWaiverMatchesFromRecords_(queries, records) { return matches; } +function staffLegacyWaiverMatchesFromRecords_(queries, records) { + const matches = {}; + (queries || []).forEach(function (query) { + const identifiers = (query.identifiers || []).map(staffNormalizeWaiverIdentifier_).filter(Boolean); + const email = staffClean_(query.email, 254).toLowerCase(); + const matched = (records || []).some(function (record) { + const recordIdentifier = staffNormalizeWaiverIdentifier_(record.A_Number); + const recordEmail = staffClean_(record.Email, 254).toLowerCase(); + return (Boolean(recordIdentifier) && identifiers.indexOf(recordIdentifier) !== -1) + || (Boolean(email) && recordEmail === email); + }); + if (matched && query.requestId) matches[String(query.requestId)] = true; + }); + return matches; +} + function staffToolLabel_(toolKey) { const cleaned = staffClean_(toolKey, 80).toLowerCase(); if (cleaned === "epilog_laser_cutter") return "Laser cutter"; @@ -194,6 +210,7 @@ if (typeof module !== "undefined") module.exports = { staffIdentifierHint_: staffIdentifierHint_, staffNormalizeWaiverIdentifier_: staffNormalizeWaiverIdentifier_, staffScrippsWaiverMatchesFromRecords_: staffScrippsWaiverMatchesFromRecords_, + staffLegacyWaiverMatchesFromRecords_: staffLegacyWaiverMatchesFromRecords_, staffToolLabel_: staffToolLabel_, staffRoleLabel_: staffRoleLabel_, staffAttentionFlags_: staffAttentionFlags_, diff --git a/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs b/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs index bd25aa1..216b6ec 100644 --- a/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs +++ b/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs @@ -4,6 +4,7 @@ const test = require("node:test"); const vm = require("node:vm"); const code = fs.readFileSync(require.resolve("../StaffCore.gs"), "utf8"); +const indexHtml = fs.readFileSync(require.resolve("../Index.html"), "utf8"); const sandbox = { module: { exports: {} } }; vm.runInNewContext(code, sandbox); const core = sandbox.module.exports; @@ -36,6 +37,25 @@ test("new Scripps waivers match completed records by normalized ID or email", () assert.deepEqual({ ...matches }, { "by-id": true, "by-email": true }); }); +test("legacy waivers match by exact normalized ID or email", () => { + const records = [ + { Name: "Raymmah Grandy Garcia", Email: "rag002@ucsd.edu", Date_Signed: "2026-08-17", A_Number: "A53258193" }, + { Name: "Maria G Diaz Gonzalez", Email: "mdiazgonzalez@ucsd.edu", Date_Signed: "2026-03-09", A_Number: "A69044638" }, + ]; + const matches = core.staffLegacyWaiverMatchesFromRecords_([ + { requestId: "raymmah", identifiers: ["53258193"], email: "" }, + { requestId: "maria", identifiers: [], email: "MDIAZGONZALEZ@UCSD.EDU" }, + { requestId: "wrong", identifiers: ["A69044639"], email: "other@ucsd.edu" }, + ], records); + assert.deepEqual({ ...matches }, { raymmah: true, maria: true }); +}); + +test("Mark left immediately exposes a disabled processing state", () => { + assert.match(indexHtml, /left\.disabled=true/); + assert.match(indexHtml, /left\.setAttribute\("aria-busy","true"\)/); + assert.match(indexHtml, /left\.textContent="Marking left…"/); +}); + test("legacy tool keys become readable approval labels", () => { assert.equal(core.staffToolLabel_("epilog_laser_cutter"), "Laser cutter"); assert.equal(core.staffToolLabel_("wood_shop"), "Wood Shop");