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/.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/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/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/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 46877e4..e3a8b45 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(); @@ -433,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) && (
@@ -459,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.

- +
@@ -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.

@@ -563,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.

- +
)} @@ -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

@@ -728,7 +764,7 @@ export default function Home() { - + diff --git a/Kiosk-v2/apps-script-registration/Code.gs b/Kiosk-v2/apps-script-registration/Code.gs index 2406dcd..5014807 100644 --- a/Kiosk-v2/apps-script-registration/Code.gs +++ b/Kiosk-v2/apps-script-registration/Code.gs @@ -1,186 +1,132 @@ const REGISTRATION_CONFIG_ = { spreadsheetProperty: "USER_DATABASE_SPREADSHEET_ID", waiverUrlProperty: "WAIVER_POWERFORM_URL", - sheetName: "Form Responses 1", - reviewSheetName: "Registration Review Log", + peopleSheet: "People", + identifiersSheet: "Identifiers", + registrationsSheet: "Registrations", consentVersion: "2026-08-11", - baseHeaders: [ - "Name", - "Timestamp", - "Card UUID", - "Student ID", - "Type", - "Email Address", - "Secondary Email", - "Waiver Signed?", - ], - reviewHeaders: [ - "Review Status", - "Registration Source", - "Registration Submitted At", - "Reviewed By", - "Reviewed At", - "Program / Department", - "Role", - "Identifier Type", - "DocuSign Status", - "Consent Version", - ], }; -function doGet() { - return HtmlService.createTemplateFromFile("Index") - .evaluate() - .setTitle("Create a Scripps Sandbox account"); +function doGet(event) { + const template = HtmlService.createTemplateFromFile("Index"); + template.isKiosk = Boolean(event && event.parameter && event.parameter.mode === "kiosk"); + return template.evaluate().setTitle("Create a Scripps Sandbox account"); } function setupRegistrationSheet() { - const sheet = openRegistrationSheet_(); - const width = Math.max(sheet.getLastColumn(), REGISTRATION_CONFIG_.baseHeaders.length); - const headers = sheet.getRange(1, 1, 1, width).getDisplayValues()[0]; - - REGISTRATION_CONFIG_.baseHeaders.forEach(function (expected, index) { - if (headers[index] !== expected) { - throw new Error("The user database header layout does not match the expected kiosk schema."); - } - }); - - const reviewSheet = openRegistrationReviewSheet_(true); - return { - ok: true, - spreadsheetId: sheet.getParent().getId(), - sheetName: sheet.getName(), - reviewSheetName: reviewSheet.getName(), - }; + const spreadsheet = openRegistrationSpreadsheet_(); + const people = requireSheet_(spreadsheet, REGISTRATION_CONFIG_.peopleSheet); + const identifiers = requireSheet_(spreadsheet, REGISTRATION_CONFIG_.identifiersSheet); + const registrations = requireSheet_(spreadsheet, REGISTRATION_CONFIG_.registrationsSheet); + assertHeaders_(people, ["Person ID", "Status", "Display Name", "Role", "Primary Email", "Secondary Emails", "Created At", "Updated At", "Source System", "Source Rows"]); + assertHeaders_(identifiers, ["Identifier ID", "Person ID", "Type", "Value", "Normalized Value", "Primary", "Verified", "Active", "Created At", "Source System", "Source Rows"]); + assertHeaders_(registrations, ["Registration ID", "Person ID", "Status", "Submitted At", "Reviewed By", "Reviewed At", "Program / Department", "Identifier Type", "DocuSign Status", "Consent Version", "Anticipated Graduation", "Source"]); + return { ok: true, spreadsheetId: spreadsheet.getId(), spreadsheetName: spreadsheet.getName(), peopleSheet: people.getName(), identifiersSheet: identifiers.getName(), registrationsSheet: registrations.getName() }; } function registrationStatus() { - const sheet = openRegistrationSheet_(); - return { - configured: true, - spreadsheetId: sheet.getParent().getId(), - spreadsheetName: sheet.getParent().getName(), - sheetName: sheet.getName(), - reviewSheetName: openRegistrationReviewSheet_(false).getName(), - waiverConfigured: Boolean( - PropertiesService.getScriptProperties().getProperty(REGISTRATION_CONFIG_.waiverUrlProperty) - ), - }; + const result = setupRegistrationSheet(); + result.configured = true; + result.waiverConfigured = Boolean(PropertiesService.getScriptProperties().getProperty(REGISTRATION_CONFIG_.waiverUrlProperty)); + return result; } function submitRegistration(payload) { const validated = validateRegistration_(payload, Date.now()); if (!validated.ok) return validated; - const lock = LockService.getScriptLock(); - if (!lock.tryLock(10000)) { - return { ok: false, message: "The registration system is busy. Please try again." }; - } - + lock.waitLock(20000); try { - const sheet = openRegistrationSheet_(); - const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getDisplayValues()[0]; - assertRegistrationHeaders_(headers); - - const lastRow = sheet.getLastRow(); - if (lastRow > 1) { - const existing = sheet.getRange(2, 4, lastRow - 1, 3).getDisplayValues(); - const duplicate = existing.some(function (row) { - const existingIdentifier = cleanText_(row[0], 32).toUpperCase().replace(/[\s-]+/g, ""); - const submittedIdentifier = validated.value.identifier.replace(/[\s-]+/g, ""); - const existingEmail = normalizeEmail_(row[2]); - return existingIdentifier === submittedIdentifier || existingEmail === validated.value.primaryEmail; - }); - if (duplicate) { - return { - ok: false, - code: "ALREADY_EXISTS", - message: "An account already exists for that ID or email. Please use your existing account or ask Sandbox staff for help.", - }; - } - } - - const submittedAt = new Date(); - const valuesByHeader = { - "Name": sheetSafe_(validated.value.name), - "Timestamp": submittedAt, - "Card UUID": "", - "Student ID": sheetSafe_(validated.value.identifier), - "Type": sheetSafe_(validated.value.role), - "Email Address": sheetSafe_(validated.value.primaryEmail), - "Secondary Email": sheetSafe_(validated.value.secondaryEmail), - "Waiver Signed?": "", - "Review Status": "Unreviewed", - "Registration Source": "Online registration", - "Registration Submitted At": submittedAt, - "Reviewed By": "", - "Reviewed At": "", - "Program / Department": sheetSafe_(validated.value.affiliation), - "Role": sheetSafe_(validated.value.role), - "Identifier Type": sheetSafe_(validated.value.identifierType), - "DocuSign Status": "Awaiting verification", - "Consent Version": REGISTRATION_CONFIG_.consentVersion, - }; - - const row = headers.map(function (header) { - return Object.prototype.hasOwnProperty.call(valuesByHeader, header) ? valuesByHeader[header] : ""; - }); - const reviewSheet = openRegistrationReviewSheet_(false); - const reviewHeaders = reviewSheet.getRange(1, 1, 1, reviewSheet.getLastColumn()).getDisplayValues()[0]; - const userDatabaseRow = sheet.getLastRow() + 1; - const reviewRow = reviewHeaders.map(function (header) { - if (header === "User Database Row") return userDatabaseRow; - return Object.prototype.hasOwnProperty.call(valuesByHeader, header) ? valuesByHeader[header] : ""; + const value = validated.value; + const spreadsheet = openRegistrationSpreadsheet_(); + const people = requireSheet_(spreadsheet, REGISTRATION_CONFIG_.peopleSheet); + const identifiers = requireSheet_(spreadsheet, REGISTRATION_CONFIG_.identifiersSheet); + const registrations = requireSheet_(spreadsheet, REGISTRATION_CONFIG_.registrationsSheet); + const identifierHeaders = getHeaders_(identifiers); + const identifierRows = identifiers.getLastRow() > 1 ? identifiers.getRange(2, 1, identifiers.getLastRow() - 1, identifierHeaders.length).getDisplayValues() : []; + const typeLabel = identifierTypeLabel_(value.identifierType); + const normalizedIdentifier = canonicalIdentifier_(value.identifier, value.identifierType); + const normalizedEmail = value.primaryEmail.toLowerCase(); + const typeIndex = identifierHeaders.indexOf("Type"); + const normalizedIndex = identifierHeaders.indexOf("Normalized Value"); + const activeIndex = identifierHeaders.indexOf("Active"); + const duplicate = identifierRows.some(function (row) { + const active = String(row[activeIndex]).toUpperCase(); + if (active === "FALSE" || active === "NO" || active === "0") return false; + const rowType = String(row[typeIndex]); + const rowValue = String(row[normalizedIndex]).trim(); + if (rowType === "Email") return rowValue.toLowerCase() === normalizedEmail; + return rowType === typeLabel && canonicalIdentifier_(rowValue, value.identifierType) === normalizedIdentifier; }); - sheet.appendRow(row); - try { - reviewSheet.appendRow(reviewRow); - } catch (reviewError) { - sheet.deleteRow(userDatabaseRow); - throw reviewError; - } - - return { - ok: true, - firstName: validated.value.firstName, - waiverUrl: getRequiredScriptProperty_(REGISTRATION_CONFIG_.waiverUrlProperty), - }; - } catch (error) { - return { - ok: false, - message: "We could not create the account. No information was intentionally retained by this form. Please try again or ask Sandbox staff for help.", - }; + if (duplicate) return { ok: false, message: "An account already exists for that ID or email. Return to the kiosk and choose I already registered." }; + + const now = new Date(); + const personId = newId_("person"); + const source = "Online registration"; + appendNamedRow_(people, { "Person ID": personId, "Status": "Active", "Display Name": value.name, "Role": value.role, "Primary Email": value.primaryEmail, "Secondary Emails": value.secondaryEmail || "", "Created At": now, "Updated At": now, "Source System": source, "Source Rows": "" }); + appendIdentifierRow_(identifiers, { "Identifier ID": newId_("identifier"), "Person ID": personId, "Type": typeLabel, "Value": value.identifier, "Normalized Value": normalizedIdentifier, "Primary": true, "Verified": false, "Active": true, "Created At": now, "Source System": source, "Source Rows": "" }); + appendIdentifierRow_(identifiers, { "Identifier ID": newId_("identifier"), "Person ID": personId, "Type": "Email", "Value": value.primaryEmail, "Normalized Value": normalizedEmail, "Primary": true, "Verified": false, "Active": true, "Created At": now, "Source System": source, "Source Rows": "" }); + appendNamedRow_(registrations, { "Registration ID": newId_("registration"), "Person ID": personId, "Status": "Unreviewed", "Submitted At": now, "Reviewed By": "", "Reviewed At": "", "Program / Department": value.affiliation, "Identifier Type": value.identifierType, "DocuSign Status": "Awaiting verification", "Consent Version": REGISTRATION_CONFIG_.consentVersion, "Anticipated Graduation": value.anticipatedGraduation || "", "Source": source }); + SpreadsheetApp.flush(); + return { ok: true, displayName: value.firstName, waiverUrl: getRequiredScriptProperty_(REGISTRATION_CONFIG_.waiverUrlProperty) }; } finally { lock.releaseLock(); } } -function openRegistrationSheet_() { - const spreadsheetId = getRequiredScriptProperty_(REGISTRATION_CONFIG_.spreadsheetProperty); - const spreadsheet = SpreadsheetApp.openById(spreadsheetId); - const sheet = spreadsheet.getSheetByName(REGISTRATION_CONFIG_.sheetName); - if (!sheet) throw new Error("Registration sheet was not found."); +function openRegistrationSpreadsheet_() { + return SpreadsheetApp.openById(getRequiredScriptProperty_(REGISTRATION_CONFIG_.spreadsheetProperty)); +} + +function requireSheet_(spreadsheet, name) { + const sheet = spreadsheet.getSheetByName(name); + if (!sheet) throw new Error("Registration sheet is missing: " + name); return sheet; } -function openRegistrationReviewSheet_(createIfMissing) { - const spreadsheetId = getRequiredScriptProperty_(REGISTRATION_CONFIG_.spreadsheetProperty); - const spreadsheet = SpreadsheetApp.openById(spreadsheetId); - let sheet = spreadsheet.getSheetByName(REGISTRATION_CONFIG_.reviewSheetName); - const headers = ["Student ID", "User Database Row"].concat(REGISTRATION_CONFIG_.reviewHeaders); - if (!sheet && createIfMissing) { - sheet = spreadsheet.insertSheet(REGISTRATION_CONFIG_.reviewSheetName); - const headerRange = sheet.getRange(1, 1, 1, headers.length); - headerRange.setValues([headers]); - headerRange.setBackground("#d9e2f3").setFontWeight("bold").setWrap(true); - sheet.setFrozenRows(1); - } - if (!sheet) throw new Error("Registration review sheet setup is incomplete."); - const actual = sheet.getRange(1, 1, 1, headers.length).getDisplayValues()[0]; - headers.forEach(function (header, index) { - if (actual[index] !== header) throw new Error("Registration review sheet layout does not match the expected schema."); +function getHeaders_(sheet) { + return sheet.getRange(1, 1, 1, sheet.getLastColumn()).getDisplayValues()[0]; +} + +function assertHeaders_(sheet, expected) { + const headers = getHeaders_(sheet); + expected.forEach(function (header) { + if (headers.indexOf(header) === -1) throw new Error(sheet.getName() + " is missing the " + header + " column."); }); - return sheet; +} + +function appendNamedRow_(sheet, valuesByHeader) { + const headers = getHeaders_(sheet); + const row = headers.map(function (header) { return Object.prototype.hasOwnProperty.call(valuesByHeader, header) ? valuesByHeader[header] : ""; }); + sheet.getRange(sheet.getLastRow() + 1, 1, 1, headers.length).setValues([row]); +} + +function appendIdentifierRow_(sheet, valuesByHeader) { + const headers = getHeaders_(sheet); + const nextRow = sheet.getLastRow() + 1; + const valueColumn = headers.indexOf("Value") + 1; + const normalizedColumn = headers.indexOf("Normalized Value") + 1; + if (valueColumn > 0) sheet.getRange(nextRow, valueColumn).setNumberFormat("@"); + if (normalizedColumn > 0) sheet.getRange(nextRow, normalizedColumn).setNumberFormat("@"); + const row = headers.map(function (header) { return Object.prototype.hasOwnProperty.call(valuesByHeader, header) ? valuesByHeader[header] : ""; }); + sheet.getRange(nextRow, 1, 1, headers.length).setValues([row]); +} + +function identifierTypeLabel_(type) { + if (type === "Student PID") return "PID"; + if (type === "Triton Student Number (TSN)") return "TSN"; + if (type === "Employee ID") return "Employee ID"; + return "Other UCSD ID"; +} + +function canonicalIdentifier_(identifier, type) { + const cleaned = String(identifier || "").trim().toUpperCase().replace(/[\s-]+/g, ""); + if (type === "Employee ID" && /^\d+$/.test(cleaned)) return cleaned.replace(/^0+(?=\d)/, ""); + return cleaned; +} + +function newId_(prefix) { + return prefix + "_" + Utilities.getUuid().replace(/-/g, ""); } function getRequiredScriptProperty_(name) { @@ -188,11 +134,3 @@ function getRequiredScriptProperty_(name) { if (!value) throw new Error("Registration deployment is not configured."); return value; } - -function assertRegistrationHeaders_(headers) { - REGISTRATION_CONFIG_.baseHeaders.forEach(function (header) { - if (headers.indexOf(header) === -1) { - throw new Error("Registration sheet setup is incomplete."); - } - }); -} diff --git a/Kiosk-v2/apps-script-registration/Index.html b/Kiosk-v2/apps-script-registration/Index.html index 964fe6d..b33e1ab 100644 --- a/Kiosk-v2/apps-script-registration/Index.html +++ b/Kiosk-v2/apps-script-registration/Index.html @@ -7,7 +7,8 @@ @@ -67,51 +98,49 @@

Create your Sandbox account.

YOUR CONNECTION TO UC SAN DIEGO -
CONTACT - - + +
@@ -126,15 +155,60 @@

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, ""); +}); 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 7de30f1..33bd57b 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, } @@ -331,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: @@ -372,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/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..729b7f8 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,62 @@ 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 normalized_user_identifiers(record: dict[str, Any]) -> set[str]: + values = record.get("Identifiers") or [record.get("Student ID")] + if isinstance(values, str): + values = [values] + return {normalized for value in values if (normalized := normalize_person_id(value))} + + +def users_matching_identifier(users: list[dict[str, Any]], identifier: Any) -> list[dict[str, Any]]: + normalized = normalize_person_id(identifier) + if not normalized: + return [] + return [record for record in users if normalized in normalized_user_identifiers(record)] + + 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 +115,103 @@ 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) + aliases = [ + str(record.get("Normalized Value", "")).strip() + for record in person_identifiers + if str(record.get("Type", "")).strip().lower() != "email" + and str(record.get("Normalized Value", "")).strip() + ] + 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(), + "Identifiers": tuple(dict.fromkeys(aliases)), + "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 +221,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 = users_matching_identifier(users, 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 = users_matching_identifier(self.provider.user_records(), 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, 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, - ) - - 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 = users_matching_identifier(self.provider.user_records(), 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.", - ) + 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_id = normalize_person_id(staff.get("Student ID")) if staff else "" + staff = next((record for record in users if str(record.get("Card Digest", "")).lower() == staff_digest), None) + staff_ids = normalized_user_identifiers(staff) if staff else set() 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_ids.intersection(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, - ) + 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: - - user_id = normalize_person_id(user.get("Student ID")) + 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( - ( - 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((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) 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..0497842 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, users_matching_identifier 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 = users_matching_identifier(self.users, 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,207 @@ def backend_for(provider): ) -class FakeActivitySheet: +def member(card="CARD123", person_id="person_1", student_id="A12345678", email="maker@example.com", identifiers=None): + return { + "Person ID": person_id, + "Name": "Test Maker", + "Student ID": student_id, + "Identifiers": identifiers or [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 - - -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() + assert len(sheet.appends) == 1 - 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" + assert provider.appended_rows[0][1] == "person_1" + assert all("CARD123" not in str(value) for value in provider.appended_rows[0]) -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": ""}], - ) +def test_pid_and_tsn_aliases_resolve_to_the_same_person(): + maker = member(card="", identifiers=["A12345678", "200010746"]) + provider = FakeProvider(users=[maker], waivers=[signed_waiver()]) + pid_result = backend_for(provider).check_in_identifier("A12345678") + tsn_result = backend_for(provider).check_in_identifier("200010746") + assert pid_result.outcome == "success" + assert tsn_result.outcome == "success" + assert {row[1] for row in provider.appended_rows} == {"person_1"} - result = backend_for(provider).check_in_identifier("12345678") - assert result.outcome == "success" - assert provider.appended_rows[0][3] == "12345678" +def test_tsn_alias_can_link_a_card_to_an_existing_pid_account(): + maker = member(card="", identifiers=["A12345678", "200010746"]) + staff = member(card="STAFFCARD", person_id="person_staff", student_id="A87654321") + provider = FakeProvider(users=[maker, staff]) + result = backend_for(provider).link_card("200010746", "NEWCARD", "STAFFCARD", {"A87654321"}) + assert result.outcome == "card_linked" + assert provider.calls["card_update"] == 1 -def test_unknown_identifier_does_not_read_waivers_or_write(): - provider = FakeProvider() +def test_waiver_on_pid_allows_checkin_through_tsn_alias(): + maker = member(card="", identifiers=["A12345678", "200010746"]) + provider = FakeProvider(users=[maker], waivers=[signed_waiver(student_id="A12345678")]) + assert backend_for(provider).check_in_identifier("200010746").outcome == "success" - result = backend_for(provider).check_in_identifier("A99999999") +def test_unknown_identifier_does_not_read_waivers_or_write(): + 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 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/pid-tsn-crosswalk.md b/Kiosk-v2/docs/pid-tsn-crosswalk.md new file mode 100644 index 0000000..e744b09 --- /dev/null +++ b/Kiosk-v2/docs/pid-tsn-crosswalk.md @@ -0,0 +1,45 @@ +# PID-to-TSN crosswalk + +## Identity rule + +A person has one stable `Person ID`. PID and TSN are verified aliases in the +`Identifiers` table. Adding a TSN must never create, rename, or merge a person. + +For a continuing student the rows look like: + +| Person ID | Type | Normalized Value | Primary | Verified | Active | +| --- | --- | --- | --- | --- | --- | +| `person_…` | PID | `A12345678` | TRUE | TRUE | TRUE | +| `person_…` | TSN | `200010746` | FALSE | TRUE | TRUE | + +New students who only have a TSN may use TSN as their primary identifier. + +## Controlled import + +1. Obtain an authoritative crosswalk with `Person ID`, PID, TSN, source, + verifier, and verification timestamp. Do not infer a mapping from name alone. +2. Run the no-write planner in `lib/sync/identifier-crosswalk.ts`. +3. Stop the entire batch if a PID or TSN is already active on another person, + a row is incomplete, or the existing database already contains conflicting + ownership. +4. Have a second staff member review counts and conflicts. +5. Append only missing aliases to `Identifiers`; never replace a `Person ID`. +6. Verify that PID and TSN both resolve to the same person at the kiosk and that + waiver lookup succeeds through either alias. +7. Retain the source and verification provenance. Rollback deactivates only the + newly appended alias rows; it does not delete people, visits, cards, or PIDs. + +## Registration duplicate prevention + +Before creating an account, registration must resolve the submitted PID or TSN +against every active alias. A unique match returns the existing-account flow. +No match may create a new person. Multiple matches are a blocker for staff +review. Email remains a second duplicate signal, not authority to merge two +people. + +## Migration safety lesson + +Legacy rows sharing an identifier are not automatically the same person. When +both normalized name and email differ, quarantine the collision for review. +The Caitlyn Webster / Natalie Levy collision is the reference case for this +guardrail. 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. 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/lib/sync/identifier-crosswalk.ts b/Kiosk-v2/lib/sync/identifier-crosswalk.ts new file mode 100644 index 0000000..f0358b5 --- /dev/null +++ b/Kiosk-v2/lib/sync/identifier-crosswalk.ts @@ -0,0 +1,115 @@ +export type ExistingIdentifier = { + personId: string; + type: string; + normalizedValue: string; + active: boolean; +}; + +export type CrosswalkInput = { + personId: string; + pid: string; + tsn: string; + verifiedBy: string; + verifiedAt: string; + source: string; +}; + +export type PlannedAlias = { + personId: string; + type: "PID" | "TSN"; + normalizedValue: string; + primary: boolean; + verified: true; + active: true; + provenance: string; +}; + +export type CrosswalkPlan = { + aliasesToCreate: PlannedAlias[]; + alreadyPresent: PlannedAlias[]; + errors: string[]; +}; + +export function normalizePid(value: string): string { + const compact = value.normalize("NFKC").toUpperCase().replace(/[\s-]+/g, ""); + if (!/^A?\d{8}$/.test(compact)) return ""; + return compact.startsWith("A") ? compact : `A${compact}`; +} + +export function normalizeTsn(value: string): string { + const compact = value.normalize("NFKC").replace(/[\s-]+/g, ""); + return /^\d{9}$/.test(compact) ? compact : ""; +} + +/** + * Produces a no-write plan. A caller may persist aliases only when errors is + * empty. The same normalized identifier may never belong to two people. + */ +export function planIdentifierCrosswalk( + rows: CrosswalkInput[], + existing: ExistingIdentifier[], +): CrosswalkPlan { + const errors: string[] = []; + const aliasesToCreate: PlannedAlias[] = []; + const alreadyPresent: PlannedAlias[] = []; + const ownerByAlias = new Map(); + const plannedKeys = new Set(); + + for (const identifier of existing) { + if (!identifier.active) continue; + const type = identifier.type.trim().toUpperCase(); + const value = type === "PID" ? normalizePid(identifier.normalizedValue) + : type === "TSN" ? normalizeTsn(identifier.normalizedValue) + : ""; + if (!value) continue; + const key = `${type}:${value}`; + const priorOwner = ownerByAlias.get(key); + if (priorOwner && priorOwner !== identifier.personId) { + errors.push(`Existing ${type} is assigned to more than one person.`); + } else { + ownerByAlias.set(key, identifier.personId); + } + } + + for (const [index, row] of rows.entries()) { + const rowNumber = index + 2; + const personId = row.personId.trim(); + const pid = normalizePid(row.pid); + const tsn = normalizeTsn(row.tsn); + if (!personId || !pid || !tsn || !row.verifiedBy.trim() || !row.verifiedAt.trim() || !row.source.trim()) { + errors.push(`Crosswalk row ${rowNumber} is incomplete or contains an invalid PID/TSN.`); + continue; + } + + const provenance = `${row.source.trim()} | verified ${row.verifiedAt.trim()} by ${row.verifiedBy.trim()}`; + for (const alias of [ + { type: "PID" as const, normalizedValue: pid, primary: true }, + { type: "TSN" as const, normalizedValue: tsn, primary: false }, + ]) { + const key = `${alias.type}:${alias.normalizedValue}`; + const owner = ownerByAlias.get(key); + if (owner && owner !== personId) { + errors.push(`Crosswalk row ${rowNumber} would assign ${alias.type} to a different person.`); + continue; + } + ownerByAlias.set(key, personId); + const plannedKey = `${personId}:${key}`; + if (plannedKeys.has(plannedKey)) continue; + plannedKeys.add(plannedKey); + const planned: PlannedAlias = { + personId, + ...alias, + verified: true, + active: true, + provenance, + }; + const exists = existing.some((item) => + item.active && item.personId === personId && item.type.trim().toUpperCase() === alias.type && + (alias.type === "PID" ? normalizePid(item.normalizedValue) : normalizeTsn(item.normalizedValue)) === alias.normalizedValue + ); + (exists ? alreadyPresent : aliasesToCreate).push(planned); + } + } + + return { aliasesToCreate: errors.length ? [] : aliasesToCreate, alreadyPresent, errors }; +} diff --git a/Kiosk-v2/lib/sync/reconciliation.ts b/Kiosk-v2/lib/sync/reconciliation.ts index 965554d..b2cc9c8 100644 --- a/Kiosk-v2/lib/sync/reconciliation.ts +++ b/Kiosk-v2/lib/sync/reconciliation.ts @@ -49,6 +49,7 @@ export type ReconciliationIssue = { type: | "missing_user_identifier" | "duplicate_user_identifier" + | "identifier_shared_across_people" | "same_account_repeated_card" | "card_shared_across_accounts" | "duplicate_waiver_identifier" @@ -154,6 +155,13 @@ function splitName(name: string): { firstName: string; lastName: string } { return { firstName: parts[0] ?? "", lastName: parts.slice(1).join(" ") }; } +function isIdentityCollision(group: LegacyUserRow[]): boolean { + if (group.length < 2) return false; + const names = new Set(group.map((row) => row.name.trim().toLowerCase()).filter(Boolean)); + const emails = new Set(group.map((row) => normalizeEmail(row.email)).filter(Boolean)); + return names.size > 1 && emails.size > 1; +} + function isCheckin(value: string): boolean { return value.trim().toLowerCase() === "user checkin"; } @@ -184,6 +192,16 @@ export function reconcileSourceSnapshot(snapshot: SourceSnapshot): Reconciliatio const duplicateUserGroups = [...userGroups.values()].filter((group) => group.length > 1); for (const group of duplicateUserGroups) { + if (isIdentityCollision(group)) { + issues.push({ + type: "identifier_shared_across_people", + sourceSheet: USER_SHEET, + sourceRowNumbers: group.map((row) => row.rowNumber), + severity: "blocker", + recommendedHandling: "Quarantine every row until staff confirms identifier ownership; do not merge people by identifier alone.", + }); + continue; + } issues.push({ type: "duplicate_user_identifier", sourceSheet: USER_SHEET, @@ -206,6 +224,7 @@ export function reconcileSourceSnapshot(snapshot: SourceSnapshot): Reconciliatio const accounts: CanonicalAccount[] = []; for (const [normalizedIdentifier, group] of userGroups) { + if (isIdentityCollision(group)) continue; const ordered = [...group].sort((a, b) => a.rowNumber - b.rowNumber); const canonicalUser = ordered.at(-1)!; const { firstName, lastName } = splitName(canonicalUser.name); diff --git a/Kiosk-v2/tests/identifier-crosswalk.test.ts b/Kiosk-v2/tests/identifier-crosswalk.test.ts new file mode 100644 index 0000000..4d939ee --- /dev/null +++ b/Kiosk-v2/tests/identifier-crosswalk.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizePid, normalizeTsn, planIdentifierCrosswalk } from "../lib/sync/identifier-crosswalk.ts"; + +test("normalizes PID and TSN without making them interchangeable", () => { + assert.equal(normalizePid("1234-5678"), "A12345678"); + assert.equal(normalizeTsn("200-010-746"), "200010746"); + assert.equal(normalizePid("200010746"), ""); + assert.equal(normalizeTsn("A12345678"), ""); +}); + +test("plans a TSN alias on the same person as an existing PID", () => { + const plan = planIdentifierCrosswalk([{ + personId: "person_1", pid: "A12345678", tsn: "200010746", + verifiedBy: "staff@ucsd.edu", verifiedAt: "2026-08-13", source: "UCSD crosswalk", + }], [{ personId: "person_1", type: "PID", normalizedValue: "A12345678", active: true }]); + assert.deepEqual(plan.errors, []); + assert.equal(plan.alreadyPresent.length, 1); + assert.deepEqual(plan.aliasesToCreate.map((alias) => [alias.type, alias.personId]), [["TSN", "person_1"]]); +}); + +test("blocks a PID or TSN that is already owned by another person", () => { + const plan = planIdentifierCrosswalk([{ + personId: "person_2", pid: "A12345678", tsn: "200010746", + verifiedBy: "staff@ucsd.edu", verifiedAt: "2026-08-13", source: "UCSD crosswalk", + }], [{ personId: "person_1", type: "PID", normalizedValue: "A12345678", active: true }]); + assert.ok(plan.errors.some((error) => error.includes("different person"))); + assert.deepEqual(plan.aliasesToCreate, []); +}); + +test("requires verification provenance before planning writes", () => { + const plan = planIdentifierCrosswalk([{ + personId: "person_1", pid: "A12345678", tsn: "200010746", + verifiedBy: "", verifiedAt: "", source: "", + }], []); + assert.equal(plan.errors.length, 1); + assert.deepEqual(plan.aliasesToCreate, []); +}); diff --git a/Kiosk-v2/tests/reconciliation.test.ts b/Kiosk-v2/tests/reconciliation.test.ts index 79113fb..f2df625 100644 --- a/Kiosk-v2/tests/reconciliation.test.ts +++ b/Kiosk-v2/tests/reconciliation.test.ts @@ -75,6 +75,20 @@ test("uses the latest repeated user row and merges cards and positive training", assert.equal(result.metrics.duplicateUserGroups, 1); }); +test("quarantines a shared identifier when both name and email identify different people", () => { + const result = reconcileSourceSnapshot(snapshot({ + users: [ + user(84, { name: "Natalie Levy", email: "nrlevy@ucsd.edu", identifier: "260053" }), + user(243, { name: "Caitlyn Webster", email: "cwebster@ucsd.edu", identifier: "260053" }), + ], + })); + + assert.equal(result.accounts.length, 0); + assert.ok(result.issues.some((issue) => + issue.type === "identifier_shared_across_people" && issue.severity === "blocker" + )); +}); + test("deduplicates a repeated card within one account", () => { const result = reconcileSourceSnapshot(snapshot({ users: [user(2, { cardUid: "same-card" }), user(3, { identifier: "A00000002", cardUid: "same-card" })], 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"), ""); +});