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..88888f0 100644 --- a/Kiosk-v2/app/globals.css +++ b/Kiosk-v2/app/globals.css @@ -394,6 +394,8 @@ h1 { .arrival-alert p { margin: 0 0 3px; font: 800 clamp(11px, .75vw, 14px)/1.1 "Jost"; letter-spacing: .13em; } .arrival-alert strong { font: 650 clamp(17px, 1.25vw, 24px)/1.15 "Jost"; } .closing-alert { background: var(--cream); } +.closing-alert.closed-alert { background: var(--navy); color: var(--cream); box-shadow: 7px 7px 0 var(--orange); } +.closing-alert.closed-alert .alert-index { border-color: var(--cream); color: var(--orange); } .home-copy.has-alert h1 { font-size: clamp(45px, 4.3vw, 78px); } .home-copy.has-alert .lede { margin-top: .85em; } .home-copy.has-alert .primary-actions { margin-top: clamp(24px, 3.5vh, 44px); } @@ -460,14 +462,95 @@ input:focus { border-color: var(--orange); } .choice-grid button { display: flex; justify-content: space-between; gap: 14px; min-height: 70px; padding: 18px 20px; border: 0; background: var(--gray); cursor: pointer; text-align: left; font-size: clamp(16px, 1.15vw, 22px); } .choice-grid button:hover, .choice-grid button.selected { background: var(--cream); color: var(--navy); } +.profile-shell { width: min(100%, 650px); } +.profile-card { + position: relative; + width: 100%; + padding: clamp(30px, 3.5vw, 48px); + border: 3px solid var(--navy); + background: var(--cream); + color: var(--navy); + box-shadow: 12px 12px 0 var(--orange); + animation: profile-card-in .42s cubic-bezier(.2,.82,.24,1) both; +} +@keyframes profile-card-in { + from { opacity: 0; transform: translateY(20px) scale(.975); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.profile-card-tab { + position: absolute; + top: -19px; + left: 28px; + padding: 8px 13px; + background: var(--navy); + color: var(--cream); + font: 800 11px/1 "Jost"; + letter-spacing: .15em; +} +.profile-card .eyebrow { margin-bottom: .9em; } +.profile-card h1 { font-size: clamp(40px, 3.85vw, 68px); line-height: .96; } +.profile-card .lede { max-width: 590px; color: #35485b; font-size: clamp(18px, 1.3vw, 25px); } +.profile-card .back { margin-bottom: clamp(20px, 2.5vh, 34px); color: var(--navy); } +.profile-card .solid-action { display: inline-flex; align-items: center; gap: 16px; margin-top: 30px; } +.profile-card .quiet-action { margin: 20px 0 0 14px; color: var(--navy); } +.profile-pulse { display: flex; height: 28px; align-items: flex-end; gap: 5px; margin-bottom: 24px; } +.profile-pulse i { width: 8px; background: var(--cyan); animation: profile-pulse 1s ease-in-out infinite alternate; } +.profile-pulse i:nth-child(1) { height: 10px; } +.profile-pulse i:nth-child(2) { height: 19px; animation-delay: .12s; } +.profile-pulse i:nth-child(3) { height: 28px; background: var(--orange); animation-delay: .24s; } +@keyframes profile-pulse { to { opacity: .45; transform: scaleY(.72); transform-origin: bottom; } } + +.profile-form { width: 100%; max-width: none; margin-top: clamp(28px, 3.2vh, 42px); } +.profile-fields { display: grid; gap: 12px; } +.profile-fields label { margin: 0; color: var(--navy); } +.profile-fields select { + display: block; + width: 100%; + min-height: 68px; + padding: 14px 52px 14px 18px; + border: 3px solid var(--navy); + border-radius: 0; + outline: 0; + background: #fffdf7; + color: var(--navy); + font: 650 clamp(18px, 1.3vw, 24px)/1.2 "Jost"; +} +.profile-fields select:focus { border-color: var(--orange); box-shadow: 0 0 0 4px rgba(247,147,30,.2); } +.profile-fields select option { background: #fffdf7; color: var(--navy); } +.profile-fields input { + padding: 16px 14px; + border: 3px solid var(--navy); + background: #fffdf7; + color: var(--navy); + font-size: clamp(22px, 2vw, 34px); + color-scheme: light; +} +.profile-form > .solid-action { margin-top: 34px; } +.profile-card .reader-offline-notice { color: var(--navy); } +.profile-role-grid { max-width: none; margin-top: 26px; gap: 2px; background: transparent; } +.profile-role-grid button { + min-height: 58px; + padding: 13px 16px; + border: 2px solid var(--navy); + background: #fffdf7; + color: var(--navy); + font-size: clamp(15px, 1vw, 19px); +} +.profile-role-grid button:hover, .profile-role-grid button:focus-visible { background: var(--orange); } + .onboarding-grid { display: grid; grid-template-columns: 1fr 210px; gap: clamp(30px, 4vw, 70px); align-items: center; margin: 28px 0; } -.onboarding-grid ol { 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; } @@ -495,6 +578,53 @@ input:focus { border-color: var(--orange); } .visit-card b { font-size: 24px; } .solid-action.navy { background: var(--navy); color: var(--cream); } .success-plane .reset-note { color: rgba(16,43,78,.66); } +.success-closing-soon { + background: var(--navy); + color: var(--cream); +} +.success-closing-soon .eyebrow { color: var(--orange); } +.success-closing-soon .lede { max-width: 780px; } +.success-closing-soon .visit-card { border-color: rgba(242,238,227,.45); } +.success-closing-soon .solid-action.navy { background: var(--orange); color: var(--navy); } +.success-closing-soon .reset-note { color: rgba(242,238,227,.7); } +.closing-time-badge { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 22px; + padding: 12px 18px 9px; + border: 3px solid var(--orange); + color: var(--orange); + transform: rotate(-2deg); +} +.closing-time-badge b { font: 850 54px/.8 "Jost"; } +.closing-time-badge span { font: 800 16px/1 "Jost"; letter-spacing: .14em; } +.success-closed { + overflow: hidden; + background: #071827; + color: var(--cream); +} +.success-closed::before { + position: absolute; + inset: 0; + background: repeating-linear-gradient(-45deg, transparent 0 62px, rgba(247,147,30,.075) 62px 124px); + content: ""; +} +.success-closed > * { position: relative; z-index: 1; } +.success-closed .eyebrow { color: var(--orange); } +.success-closed .lede { max-width: 760px; } +.success-closed .visit-card { border-color: rgba(242,238,227,.38); } +.success-closed .solid-action.navy { background: var(--orange); color: var(--navy); } +.success-closed .reset-note { color: rgba(242,238,227,.7); } +.closed-stripe { + margin-bottom: 22px; + padding: 10px 25px; + border: 3px solid var(--orange); + color: var(--orange); + font: 850 clamp(34px, 4vw, 66px)/1 "Jost"; + letter-spacing: .13em; + transform: rotate(-2deg); +} .success-closing-alert { display: grid; grid-template-columns: 72px minmax(0, 430px); @@ -560,6 +690,21 @@ input:focus { border-color: var(--orange); } .staff-poster b { position: relative; z-index: 1; font: 650 clamp(52px, 7vw, 128px)/.82 "Jost"; letter-spacing: -.07em; transform: rotate(-7deg); } .staff-panel > form { width: min(720px, 100%); max-width: none; margin: 0; padding: clamp(34px, 5vh, 70px) clamp(36px, 6vw, 100px); overflow-y: auto; } .staff-panel .demo-heading { margin-bottom: 18px; } +.release-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 5px 24px; + margin: 0 0 22px; + padding: 16px 18px; + border: 2px solid var(--navy); + background: #fffaf0; + box-shadow: 6px 6px 0 var(--orange); +} +.release-card div { display: flex; align-items: baseline; gap: 12px; } +.release-card small { font: 750 10px/1.2 "Jost"; letter-spacing: .14em; } +.release-card strong { font: 750 22px/1 "Jost"; letter-spacing: .02em; } +.release-card time { color: #53606b; font: 650 14px/1.2 "Jost"; } +.release-card p { grid-column: 1 / -1; margin: 0; color: #53606b; font-size: 14px; line-height: 1.3; } .staff-intro { max-width: 610px; margin: 0 0 22px; color: #53606b; font-size: 18px; line-height: 1.4; } .preset-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 26px; } .preset-row button { padding: 9px 13px; border: 1px solid var(--navy); background: transparent; color: var(--navy); cursor: pointer; font-weight: 650; } @@ -591,6 +736,33 @@ footer { position: absolute; right: clamp(28px, 4vw, 72px); bottom: clamp(22px, footer span { font-family: "Jost"; font-size: 11px; font-weight: 700; letter-spacing: .18em; } footer img { width: clamp(150px, 14vw, 250px); height: 70px; object-fit: contain; object-position: right bottom; filter: brightness(0) invert(1); opacity: .88; } .screen-success footer { filter: brightness(0) saturate(100%) invert(13%) sepia(21%) saturate(2873%) hue-rotate(173deg) brightness(92%); } +.screen-success:has(.success-closing-soon) footer, +.screen-success:has(.success-closed) footer { filter: none; } + +.idle-return { + position: fixed; + left: 50%; + bottom: 22px; + z-index: 35; + display: grid; + grid-template-columns: 52px minmax(180px, 1fr) auto; + align-items: center; + gap: 14px; + width: min(620px, calc(100vw - 48px)); + padding: 12px 14px; + border: 2px solid var(--navy); + background: var(--cream); + color: var(--navy); + box-shadow: 7px 7px 0 var(--orange); + transform: translateX(-50%); +} +.idle-ring { --idle-progress: 360deg; position: relative; width: 48px; height: 48px; flex: 0 0 48px; border-radius: 50%; background: conic-gradient(var(--orange) var(--idle-progress), #d9d5cb 0); } +.idle-ring::before { position: absolute; inset: 6px; border-radius: 50%; background: var(--cream); content: ""; } +.idle-ring span { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; color: var(--navy); font: 750 15px/1 "Jost"; } +.idle-return small, .idle-return b { display: block; } +.idle-return small { margin-bottom: 3px; font: 800 10px/1 "Jost"; letter-spacing: .13em; } +.idle-return b { font: 600 15px/1.2 "Jost"; } +.idle-return button { min-height: 42px; padding: 8px 13px; border: 2px solid var(--navy); background: transparent; color: var(--navy); cursor: pointer; font-weight: 750; } button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .success-plane button:focus-visible { outline-color: var(--navy); } @@ -608,6 +780,10 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .staff-panel { grid-template-columns: 1fr; overflow-y: auto; } .staff-poster { display: none; } .staff-panel > form { overflow: visible; } + .release-card { grid-template-columns: 1fr; } + .release-card time { grid-row: 2; } + .release-card p { grid-column: 1; } + .profile-card { padding: 34px 26px; } } @media (max-height: 760px) and (min-width: 881px) { @@ -620,9 +796,27 @@ button:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; } .home-copy.has-alert .primary-actions { margin-top: 18px; } .home-copy.has-alert .text-action { padding-block: 9px; } .success-closing-alert { margin-top: 14px; } + .profile-card { padding: 30px 34px; } + .profile-card h1 { font-size: clamp(38px, 4vw, 60px); } + .profile-card .lede { font-size: 18px; } + .profile-card .back { margin-bottom: 16px; } + .profile-role-grid { margin-top: 18px; } + .profile-role-grid button { min-height: 49px; padding: 10px 13px; font-size: 14px; } footer { bottom: 18px; } } @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); } +.group-link-content { width: min(100%, 820px); } +.group-link-callout { display: grid; gap: 6px; max-width: 620px; margin: 28px 0; padding: 18px 20px; border-left: 8px solid var(--orange); background: var(--cream); color: var(--navy); box-shadow: 8px 8px 0 rgba(16,43,78,.5); } +.group-link-callout b { font: 850 13px/1 "Jost"; letter-spacing: .14em; } +.group-link-callout span { font-size: 18px; } +.waiver-required-content .button-row { margin-top: 28px; } +.waiver-delay-note { display: grid; gap: 6px; margin-top: 24px; padding: 16px 18px; border-left: 7px solid var(--orange); background: var(--cream); color: var(--navy); } +.waiver-delay-note b { font: 850 14px/1 "Jost"; letter-spacing: .13em; } +.waiver-delay-note span { font-size: clamp(16px, 1.05vw, 20px); line-height: 1.3; } +.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; } .idle-return { bottom: 18px; grid-template-columns: 46px 1fr; } .idle-return button { grid-column: 1 / -1; } } 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..ec4f9b4 100644 --- a/Kiosk-v2/app/page.tsx +++ b/Kiosk-v2/app/page.tsx @@ -1,7 +1,18 @@ "use client"; -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { QRCodeSVG } from "qrcode.react"; +import { + PROFILE_ROLES, + affiliationOptions, + emptyProfile, + nextProfileQuestion, + normalizedProfileAnswer, + profileQuestionForField, + type ProfileField, + type ProfileSnapshot, +} from "@/lib/profile-enrichment"; +import { KIOSK_RELEASE } from "@/lib/kiosk-release"; type Screen = | "home" @@ -16,6 +27,8 @@ type Screen = | "link-authorize" | "link-error" | "card-linked" + | "group-link" + | "group-link-error" | "reader-error" | "profile" | "profile-detail" @@ -26,9 +39,11 @@ type Announcement = { heading: string; body: string; closingTime: string; + closingDate: string; }; 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"; @@ -41,7 +56,15 @@ type ScannerOutcome = | "backend_error" | "card_linked" | "staff_unauthorized" - | "card_link_error"; + | "card_link_error" + | "group_card_linked" + | "group_link_error"; + +type GroupLinkRequest = { + request_id: string; + display_name: string; + expires_at: string; +}; type ScannerDetectedEvent = { type: "card_detected"; @@ -58,6 +81,8 @@ type ScannerResultEvent = { read_at: string; sequence: number; processing_ms?: number; + person_id?: string | null; + profile?: Partial; }; type ScannerEvent = ScannerDetectedEvent | ScannerResultEvent; @@ -67,33 +92,55 @@ const emptyAnnouncement: Announcement = { heading: "CHECK IN WITH STAFF", body: "Please check in with me upstairs before starting work.", closingTime: "", + closingDate: "", }; -const affiliations = [ - "Undergraduate", - "Graduate student", - "Postdoc", - "Faculty", - "Staff", - "Visitor", -]; +const INACTIVITY_SECONDS = 45; +const INACTIVITY_NOTICE_SECONDS = 15; + +function localDateKey(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} 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); const [pid, setPid] = useState(""); const [checkInMethod, setCheckInMethod] = useState<"card" | "identifier">("card"); - const [affiliation, setAffiliation] = useState(""); - const [detail, setDetail] = useState(""); + const [profile, setProfile] = useState(emptyProfile); + const [profileSessionAvailable, setProfileSessionAvailable] = useState(false); + const [profileAnswer, setProfileAnswer] = useState(""); + const [profileOther, setProfileOther] = useState(""); + const [profileSaving, setProfileSaving] = useState(false); + const [profileError, setProfileError] = useState(""); + const [profileEditingField, setProfileEditingField] = useState(null); const [linkIdentifier, setLinkIdentifier] = useState(""); const [linkTargetName, setLinkTargetName] = useState("Sandbox member"); const [linkError, setLinkError] = useState(""); const [staffCardDetected, setStaffCardDetected] = useState(false); const [countdown, setCountdown] = useState(8); + const [idleCountdown, setIdleCountdown] = useState(INACTIVITY_SECONDS); const [now, setNow] = useState(null); const [announcement, setAnnouncement] = useState(emptyAnnouncement); const [announcementDraft, setAnnouncementDraft] = useState(emptyAnnouncement); @@ -103,20 +150,41 @@ export default function Home() { const [scannerResult, setScannerResult] = useState(null); const [welcomeName, setWelcomeName] = useState("Sandbox member"); const [visitCount, setVisitCount] = useState(null); + const [groupLinkRequest, setGroupLinkRequest] = useState(null); + const [groupJustLinked, setGroupJustLinked] = useState(false); const demoControlsEnabled = process.env.NEXT_PUBLIC_KIOSK_DEMO === "true"; const screenRef = useRef("home"); const cardDetectedAtRef = useRef(null); + const idleDeadlineRef = useRef(null); + const groupLinkRequestRef = useRef(null); + const profileQuestion = useMemo( + () => profileSessionAvailable + ? profileEditingField + ? profileQuestionForField(profileEditingField, profile.role) + : nextProfileQuestion(profile) + : null, + [profile, profileEditingField, profileSessionAvailable], + ); useEffect(() => { screenRef.current = screen; }, [screen]); + useEffect(() => { + groupLinkRequestRef.current = groupLinkRequest; + }, [groupLinkRequest]); + useEffect(() => { setNow(new Date()); const saved = window.localStorage.getItem("sandbox-kiosk-announcement"); if (saved) { try { const parsed = { ...emptyAnnouncement, ...JSON.parse(saved) }; + if (parsed.closingTime && parsed.closingDate !== localDateKey(new Date())) { + parsed.closingTime = ""; + parsed.closingDate = ""; + window.localStorage.setItem("sandbox-kiosk-announcement", JSON.stringify(parsed)); + } setAnnouncement(parsed); setAnnouncementDraft(parsed); } catch { @@ -149,7 +217,7 @@ export default function Home() { socket.addEventListener("message", (message) => { try { const event = JSON.parse(message.data) as ScannerEvent; - if (event.type === "card_detected" && screenRef.current === "home") { + if (event.type === "card_detected" && (screenRef.current === "home" || screenRef.current === "group-link")) { cardDetectedAtRef.current = performance.now(); setScannerResult(null); setCheckInMethod("card"); @@ -196,6 +264,32 @@ export default function Home() { }; }, []); + useEffect(() => { + const isLocalKiosk = ["localhost", "127.0.0.1"].includes(window.location.hostname); + if (!isLocalKiosk || scannerStatus !== "connected") return; + let stopped = false; + async function pollGroupLink() { + try { + const response = await fetch("http://127.0.0.1:8765/group-link/status", { cache: "no-store" }); + if (!response.ok || stopped) return; + const result = await response.json() as { active?: boolean; request_id?: string; display_name?: string; expires_at?: string }; + if (result.active && result.request_id) { + const request = { request_id: result.request_id, display_name: result.display_name || "Sandbox member", expires_at: result.expires_at || "" }; + setGroupLinkRequest(request); + if (screenRef.current === "home") setScreen("group-link"); + } else { + setGroupLinkRequest(null); + if (screenRef.current === "group-link") setScreen("home"); + } + } catch { + // Normal check-in remains available if the optional staff queue cannot be reached. + } + } + pollGroupLink(); + const interval = window.setInterval(pollGroupLink, 2500); + return () => { stopped = true; window.clearInterval(interval); }; + }, [scannerStatus]); + useEffect(() => { if (screen !== "reading" || !scannerResult) return; const detectedAt = cardDetectedAtRef.current ?? performance.now(); @@ -203,17 +297,33 @@ export default function Home() { const delay = Math.max(0, minimumFeedbackMs - (performance.now() - detectedAt)); const timer = window.setTimeout(() => { if (scannerResult.outcome === "demo") { + setCountdown(8); setScreen("success"); return; } if (scannerResult.outcome === "success") { + const incomingProfile = { ...emptyProfile(), ...(scannerResult.profile || {}) }; + const hasQuestion = Boolean(scannerResult.person_id && nextProfileQuestion(incomingProfile)); + setWelcomeName(scannerResult.display_name || "Sandbox member"); + setVisitCount(scannerResult.visit_count); + setProfile(incomingProfile); + setProfileSessionAvailable(Boolean(scannerResult.person_id)); + setCountdown(8); + setScreen(hasQuestion ? "profile" : "success"); + } else if (scannerResult.outcome === "group_card_linked") { setWelcomeName(scannerResult.display_name || "Sandbox member"); setVisitCount(scannerResult.visit_count); + setGroupLinkRequest(null); + setGroupJustLinked(true); + setCountdown(8); setScreen("success"); + } else if (scannerResult.outcome === "group_link_error") { + setLinkError(scannerResult.message || "The card could not be connected. Ask staff to try again."); + setScreen("group-link-error"); } else if (scannerResult.outcome === "unknown_card") { setScreen("unknown-card"); - } else if (scannerResult.outcome === "unknown_identifier") { - setScreen("not-found"); + } else if (scannerResult.outcome === "unknown_identifier") { + setScreen("not-found"); } else if (scannerResult.outcome === "waiver_required") { setScreen("waiver-required"); } else { @@ -225,7 +335,6 @@ export default function Home() { useEffect(() => { if (screen !== "success") return; - setCountdown(8); const interval = window.setInterval(() => { setCountdown((value) => { if (value <= 1) { @@ -245,16 +354,27 @@ export default function Home() { [now], ); - const minutesUntilClose = useMemo(() => { - if (demoClosingMinutes !== null) return demoClosingMinutes; - if (!now || !announcement.closingTime) return null; + const closingStatus = useMemo(() => { + if (demoClosingMinutes !== null) { + return { + mode: demoClosingMinutes <= 0 ? "closed" as const : "closing-soon" as const, + minutes: Math.max(0, demoClosingMinutes), + }; + } + if (!now || !announcement.closingTime || announcement.closingDate !== localDateKey(now)) { + return { mode: "open" as const, minutes: null }; + } const [hours, minutes] = announcement.closingTime.split(":").map(Number); - if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null; + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return { mode: "open" as const, minutes: null }; const closes = new Date(now); closes.setHours(hours, minutes, 0, 0); const difference = Math.ceil((closes.getTime() - now.getTime()) / 60_000); - return difference >= 0 && difference <= 30 ? difference : null; - }, [announcement.closingTime, demoClosingMinutes, now]); + if (difference <= 0) return { mode: "closed" as const, minutes: 0 }; + if (difference <= 30) return { mode: "closing-soon" as const, minutes: difference }; + return { mode: "open" as const, minutes: null }; + }, [announcement.closingDate, announcement.closingTime, demoClosingMinutes, now]); + + const minutesUntilClose = closingStatus.minutes; const closingLabel = minutesUntilClose === 0 ? "We’re closing now" @@ -274,6 +394,7 @@ export default function Home() { ...announcementDraft, heading: announcementDraft.heading.trim() || emptyAnnouncement.heading, body: announcementDraft.body.trim() || emptyAnnouncement.body, + closingDate: announcementDraft.closingTime ? localDateKey(new Date()) : "", }; setAnnouncement(next); window.localStorage.setItem("sandbox-kiosk-announcement", JSON.stringify(next)); @@ -305,7 +426,19 @@ export default function Home() { setScreen("reading"); } - function reset() { + const reset = useCallback(() => { + const previousScreen = screenRef.current; + const pendingGroupLink = groupLinkRequestRef.current; + if ((previousScreen === "group-link" || previousScreen === "group-link-error") && pendingGroupLink) { + fetch("http://127.0.0.1:8765/group-link/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request_id: pendingGroupLink.request_id }), + }).catch(() => undefined); + } + if (!["home", "reading", "success"].includes(previousScreen)) { + fetch("http://127.0.0.1:8765/scanner/allow-repeat", { method: "POST" }).catch(() => undefined); + } cardDetectedAtRef.current = null; setScannerResult(null); setWelcomeName("Sandbox member"); @@ -313,14 +446,64 @@ export default function Home() { setScreen("home"); setCheckInMethod("card"); setPid(""); - setAffiliation(""); - setDetail(""); + setProfile(emptyProfile()); + setProfileSessionAvailable(false); + setProfileAnswer(""); + setProfileOther(""); + setProfileSaving(false); + setProfileError(""); + setProfileEditingField(null); setLinkIdentifier(""); setLinkTargetName("Sandbox member"); setLinkError(""); setStaffCardDetected(false); + setGroupLinkRequest(null); + setGroupJustLinked(false); setDemoOpen(false); - } + }, []); + + useEffect(() => { + const returnHome = (event: KeyboardEvent) => { + if (event.key === "Escape") reset(); + }; + window.addEventListener("keydown", returnHome); + return () => window.removeEventListener("keydown", returnHome); + }, [reset]); + + const idleTimerActive = screen !== "home" && screen !== "reading" && screen !== "success" && !staffOpen; + + const extendIdleTimer = useCallback(() => { + idleDeadlineRef.current = Date.now() + INACTIVITY_SECONDS * 1000; + setIdleCountdown(INACTIVITY_SECONDS); + }, []); + + useEffect(() => { + idleDeadlineRef.current = null; + setIdleCountdown(INACTIVITY_SECONDS); + if (!idleTimerActive) return; + + idleDeadlineRef.current = Date.now() + INACTIVITY_SECONDS * 1000; + let expired = false; + const tick = () => { + const deadline = idleDeadlineRef.current; + if (deadline === null || expired) return; + const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + setIdleCountdown(remaining); + if (remaining === 0) { + expired = true; + window.clearInterval(interval); + if (screen === "link-authorize" || screen === "link-error") { + fetch("http://127.0.0.1:8765/card-link/cancel", { method: "POST" }).catch(() => undefined); + } + reset(); + } + }; + const interval = window.setInterval(tick, 250); + return () => { + expired = true; + window.clearInterval(interval); + }; + }, [idleTimerActive, reset, screen]); async function submitPid(event: FormEvent) { event.preventDefault(); @@ -392,6 +575,80 @@ export default function Home() { reset(); } + async function saveProfileAnswer(field: ProfileField, rawValue: string) { + const value = normalizedProfileAnswer(field, rawValue); + if (!value || profileSaving) return; + setProfileSaving(true); + setProfileError(""); + try { + let updatedProfile: ProfileSnapshot; + if (demoControlsEnabled && !profileSessionAvailable) { + updatedProfile = { ...profile, [field]: value }; + if (field === "role" && profile.role && profile.role !== value) { + updatedProfile.affiliation = ""; + updatedProfile.anticipatedGraduation = ""; + } + } else { + const response = await fetch("http://127.0.0.1:8765/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ field, value }), + }); + const result = await response.json() as { + ok?: boolean; + profile?: Partial; + detail?: string; + message?: string; + }; + if (!response.ok || !result.ok) { + throw new Error(result.detail || result.message || "That answer could not be saved."); + } + updatedProfile = { ...profile, ...(result.profile || {}), [field]: value }; + } + setProfile(updatedProfile); + setProfileEditingField(null); + setProfileAnswer(""); + setProfileOther(""); + if (!nextProfileQuestion(updatedProfile)) { + setCountdown(8); + setScreen("success"); + } + } catch (error) { + setProfileError(error instanceof Error ? error.message : "That answer could not be saved."); + } finally { + setProfileSaving(false); + } + } + + function skipProfileQuestions() { + setCountdown(8); + setScreen("success"); + } + + function editProfileField(field: ProfileField) { + setProfileEditingField(field); + setProfileError(""); + const currentValue = profile[field]; + if (field === "affiliation" && currentValue.startsWith("Other – ")) { + setProfileAnswer("Other"); + setProfileOther(currentValue.slice("Other – ".length)); + } else { + setProfileAnswer(field === "role" ? "" : currentValue); + setProfileOther(""); + } + } + + function profileBack() { + if (profileQuestion?.field === "anticipatedGraduation") { + editProfileField("affiliation"); + } else if (profileQuestion?.field === "affiliation") { + editProfileField("role"); + } else { + setProfileEditingField(null); + setScreen("profile"); + } + } + const showBrand = screen !== "success"; return ( @@ -402,7 +659,13 @@ export default function Home() {
{timeLabel} - + {demoControlsEnabled &&
)} + {screen === "group-link" && groupLinkRequest && ( +
+

STAFF DESK READY

+

{groupLinkRequest.display_name.split(/\s+/)[0]}, tap your
ID card now.

+

Hold your card near the blue hand below. This will connect your card and check you in.

+
CHECK THE NAME ABOVEIf this is not you, choose Cancel and ask staff for help.
+ +
+ )} + + {screen === "group-link-error" && ( +
+

CARD NOT CONNECTED

+

Please ask staff
to try again.

+

{linkError}

+ +
+ )} + {screen === "pid" && (
- +

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.

- + - +
)} {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 and complete the DocuSign waiver.

+
+ ALLOW UP TO 15 MINUTES + Your signed waiver may take a little while to appear here. You do not need to tap repeatedly—return later and tap once. +
- - +
- )} + {WAIVER_URL && ( +
+ + SCAN TO SIGN THE WAIVER +
+ )} +
+ )} - {screen === "backend-error" && ( + {screen === "backend-error" && (

CHECK-IN NOT RECORDED

Please check in
with staff.

The card reader worked, but the attendance log did not. Staff can let you through while the kiosk reconnects.

- +
)} @@ -563,9 +856,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. Their new card will replace any card currently connected to the account.

- + Cancel

DESIGNATED STAFF AUTHORIZATION

{staffCardDetected ? <>Checking staff
authorization… : <>Staff: tap your
own ID card.}

-

Connecting the waiting card to {linkTargetName}. Only a designated staff card can approve this change.

-

Do not tap the member’s card again until authorization is complete.

+

Replacing the card for {linkTargetName}. Only a designated staff card can approve this change.

+

Approval disables the account’s previous card. Do not tap the member’s new card again until authorization is complete.

)} @@ -607,7 +900,7 @@ export default function Home() {

CARD CONNECTED

{welcomeName},
you’re ready.

-

Tap the newly connected card again to check in. A waiver is still required before entry.

+

The previous card is disabled. Tap the new card again to check in. A waiver is still required before entry.

)} @@ -618,102 +911,164 @@ export default function Home() {

The reader didn’t
catch your card.

Hold it flat against the reader for a full second.

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

YOU’RE CHECKED IN

-

One quick
question.

-

What best describes your role at UC San Diego?

-
- {affiliations.map((item) => ( - - ))} +
+
+ + +

ONE QUICK UPDATE

+

Your account is missing
a few details.

+

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

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

LAST ONE

-

Your program
or department?

-

This helps us understand who the Makerspace serves.

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

{profileQuestion.eyebrow}

+

{profileQuestion.heading}

+

{profileQuestion.prompt}

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

{profileError}

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

{profileError}

} + +
+ )} + : null} + + +
)} {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" && ( -
- -

CHECK-IN COMPLETE

-

Welcome back,
{welcomeName}.

-

You’re checked in to the Scripps Sandbox.

- {minutesUntilClose !== null && ( -
- {String(minutesUntilClose).padStart(2, "0")} -

CLOSING SOON{closingLabel}. Please choose a project you can stop safely before then.

-
+
+ {closingStatus.mode === "open" ? <> + +

{groupJustLinked ? "CARD CONNECTED · CHECK-IN COMPLETE" : "CHECK-IN COMPLETE"}

+

{groupJustLinked ? <>You’re all set,
{welcomeName}. : <>Welcome back,
{welcomeName}.}

+

{groupJustLinked ? "Your ID card is connected and today’s visit has been recorded." : "You’re checked in to the Scripps Sandbox."}

+ : closingStatus.mode === "closing-soon" ? <> + +

CHECK-IN RECORDED · CLOSING SOON

+

We close in
{minutesUntilClose} minutes.

+

Welcome, {welcomeName}. Only begin work you can stop and clean up safely before closing.

+ : <> + +

CHECK-IN RECORDED · MAKERSPACE CLOSED

+

Please see
a staff member.

+

The door may still be open, but do not begin or continue work without staff approval.

+ } + {closingStatus.mode !== "open" && ( +
+ +

+ {closingStatus.mode === "closed" ? "MAKERSPACE CLOSED" : "CLOSING SOON"} + {closingStatus.mode === "closed" + ? "Please see staff before beginning or continuing work." + : `${closingLabel}. Please plan your work and cleanup accordingly.`} +

+
)}
TODAY {timeLabel} - {visitCount === null ? "CHECKED IN" : `VISIT DAY ${visitCount}`} + {visitCount === null ? "CHECKED IN" : ordinal(visitCount) + " Visit"}

Returning home in {countdown} seconds

)} + {idleTimerActive && idleCountdown <= INACTIVITY_NOTICE_SECONDS && ( + + )} + {demoControlsEnabled && demoOpen && ( @@ -748,6 +1105,14 @@ export default function Home() {
STAFF CONTROLFront-screen message
+
+
+ CURRENT KIOSK REV + {KIOSK_RELEASE.revision} +
+ +

{KIOSK_RELEASE.summary}

+

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

@@ -760,7 +1125,7 @@ export default function Home() {
+
+ + +
STAFF ACTION

Manual check-in

Search by name, role, or program. Results show a last initial and partial ID only.

+
USER CARD

Member

Loading user card
Checking profile, attention items, and training…
+
USER PROFILE

Edit profile

Changes are saved directly to the Sandbox database.

+

Confirm laser training

This records the approval in the Sandbox database. If the FabMan member is linked, the Epilog training is added there too. Key assignment remains separate.

+
VERIFY MEMBER

Link FabMan member

Members already enrolled at Scripps can be linked directly. For another UCSD makerspace member, “Add to Scripps Sandbox” adds only the Scripps Sandbox User package—no merging, profile edits, key changes, or changes to other packages.

Searching FabMan…
+
ADMINISTRATIVE ACTION

Add to Scripps Sandbox?

This changes FabMan
Adds the Scripps Sandbox User package only. Existing memberships, packages, training, profile details, home space, and key remain unchanged.

After FabMan confirms the package, the app will link the member and copy any existing Scripps laser approval.

+ + + + + diff --git a/Kiosk-v2/apps-script-staff/README.md b/Kiosk-v2/apps-script-staff/README.md new file mode 100644 index 0000000..3785ef8 --- /dev/null +++ b/Kiosk-v2/apps-script-staff/README.md @@ -0,0 +1,23 @@ +# 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. The Staff Desk accepts an exact ID or email match from the existing `Waiver Signatures SIO` spreadsheet as well as completed records in the production `Scripps Waivers` tab. +4. In the `Staff Access` tab, make the deploying account active with role `administrator`. +5. Run `setupStaffApp()` once to create `Tool Training` and `Staff Notes`. +6. Deploy as a web app executing as the deploying account, restricted to UC San Diego users. + +During the DocuSign transition, the staff card-connection queue accepts either the existing registration waiver status or a completed record in the production spreadsheet's `Scripps Waivers` tab. `setupStaffApp()` creates that tab when needed. No external waiver-status service or additional Staff Desk secret is required. + +The app also enforces the active `Staff Access` allowlist on every server call. Roles are `staff`, `trainer`, and `administrator`; only trainers and administrators can record laser training. + +Any approved staff member can edit a member's profile from the person card. The update writes the role to `People` and the role-dependent affiliation and anticipated graduation to the latest `Registrations` row, with the staff reviewer and timestamp recorded. + +Staff whose `Staff Access` row has `Card Linking Allowed` enabled (and administrators) also see **Connect cards**. It lists the newest registered, waiver-verified accounts with no active card. A second **Not ready to connect** section shows active cardless accounts that are missing registration or a verified waiver, with a staff-facing explanation of the blocker. Selecting an eligible person creates a 45-second `Kiosk Link Requests` handoff; the kiosk revalidates the request, connects the first card, records the authorizing staff account, and checks the member in. Existing-card accounts are deliberately excluded and must use the replacement-card workflow. + +FabMan synchronization is deliberately not claimed by this MVP. A recorded approval displays `Not connected` until credentials and resource mapping are configured. diff --git a/Kiosk-v2/apps-script-staff/StaffCore.gs b/Kiosk-v2/apps-script-staff/StaffCore.gs new file mode 100644 index 0000000..df0756c --- /dev/null +++ b/Kiosk-v2/apps-script-staff/StaffCore.gs @@ -0,0 +1,219 @@ +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; +} + +const STAFF_PROFILE_ROLES_ = [ + "Academic", + "Staff", + "Postdoc", + "Graduate Student (MS)", + "Graduate Student (PhD)", + "MAS Student", + "Undergraduate Student (UG)", + "Affiliate (Retirees, Volunteers, etc.)", + "Visiting scholar or visitor", + "Community member", + "Other", +]; + +const STAFF_PROFILE_STUDENT_ROLES_ = [ + "Graduate Student (MS)", + "Graduate Student (PhD)", + "MAS Student", + "Undergraduate Student (UG)", +]; + +function staffValidateProfile_(payload) { + const input = payload || {}; + const role = staffClean_(input.role, 80); + const affiliation = staffClean_(input.affiliation, 120); + let anticipatedGraduation = staffClean_(input.anticipatedGraduation, 7); + if (STAFF_PROFILE_ROLES_.indexOf(role) === -1) return { ok: false, message: "Choose a listed role." }; + if (!affiliation) return { ok: false, message: "Choose a program, department, major, or organization." }; + const isStudent = STAFF_PROFILE_STUDENT_ROLES_.indexOf(role) !== -1; + if (isStudent && !/^\d{4}-(0[1-9]|1[0-2])$/.test(anticipatedGraduation)) { + return { ok: false, message: "Choose the student's anticipated graduation month and year." }; + } + if (!isStudent) anticipatedGraduation = ""; + return { ok: true, value: { role: role, affiliation: affiliation, anticipatedGraduation: anticipatedGraduation } }; +} + +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 staffNormalizeWaiverIdentifier_(value) { + const normalized = staffClean_(value, 120).toUpperCase().replace(/[^A-Z0-9]/g, ""); + if (/^A\d{8}$/.test(normalized)) return normalized.slice(1); + if (/^0+\d+$/.test(normalized)) return normalized.replace(/^0+(?=\d)/, ""); + return normalized; +} + +function staffScrippsWaiverMatchesFromRecords_(queries, records) { + const matches = {}; + const completed = (records || []).filter(function (record) { + return staffClean_(record.Status, 40).toLowerCase() === "completed"; + }); + (queries || []).forEach(function (query) { + const identifiers = (query.identifiers || []).map(staffNormalizeWaiverIdentifier_).filter(Boolean); + const email = staffClean_(query.email, 254).toLowerCase(); + const matched = completed.some(function (record) { + const recordIdentifier = staffNormalizeWaiverIdentifier_(record["Normalized Identifier"] || record["Participant ID"]); + const recordEmail = staffClean_(record["Participant Email"], 254).toLowerCase(); + return (Boolean(recordIdentifier) && identifiers.indexOf(recordIdentifier) !== -1) + || (Boolean(email) && recordEmail === email); + }); + if (matched && query.requestId) matches[String(query.requestId)] = true; + }); + return matches; +} + +function staffLegacyWaiverMatchesFromRecords_(queries, records) { + const matches = {}; + (queries || []).forEach(function (query) { + const identifiers = (query.identifiers || []).map(staffNormalizeWaiverIdentifier_).filter(Boolean); + const email = staffClean_(query.email, 254).toLowerCase(); + const matched = (records || []).some(function (record) { + const recordIdentifier = staffNormalizeWaiverIdentifier_(record.A_Number); + const recordEmail = staffClean_(record.Email, 254).toLowerCase(); + return (Boolean(recordIdentifier) && identifiers.indexOf(recordIdentifier) !== -1) + || (Boolean(email) && recordEmail === email); + }); + if (matched && query.requestId) matches[String(query.requestId)] = true; + }); + return matches; +} + +function staffToolLabel_(toolKey) { + const cleaned = staffClean_(toolKey, 80).toLowerCase(); + if (cleaned === "epilog_laser_cutter") return "Laser cutter"; + 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_, + staffNormalizeWaiverIdentifier_: staffNormalizeWaiverIdentifier_, + staffScrippsWaiverMatchesFromRecords_: staffScrippsWaiverMatchesFromRecords_, + staffLegacyWaiverMatchesFromRecords_: staffLegacyWaiverMatchesFromRecords_, + staffToolLabel_: staffToolLabel_, + staffRoleLabel_: staffRoleLabel_, + staffAttentionFlags_: staffAttentionFlags_, + staffVisitFlagDetails_: staffVisitFlagDetails_, + staffValidateProfile_: staffValidateProfile_, +}; 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..216b6ec --- /dev/null +++ b/Kiosk-v2/apps-script-staff/test/staff-core.test.cjs @@ -0,0 +1,109 @@ +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 indexHtml = fs.readFileSync(require.resolve("../Index.html"), "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("new Scripps waivers match completed records by normalized ID or email", () => { + const records = [ + { Status: "completed", "Participant Email": "member@ucsd.edu", "Participant ID": "A12345678", "Normalized Identifier": "12345678" }, + { Status: "voided", "Participant Email": "voided@ucsd.edu", "Participant ID": "A87654321", "Normalized Identifier": "87654321" }, + ]; + const matches = core.staffScrippsWaiverMatchesFromRecords_([ + { requestId: "by-id", identifiers: ["A12345678"], email: "" }, + { requestId: "by-email", identifiers: [], email: "MEMBER@UCSD.EDU" }, + { requestId: "voided", identifiers: ["A87654321"], email: "voided@ucsd.edu" }, + ], records); + assert.deepEqual({ ...matches }, { "by-id": true, "by-email": true }); +}); + +test("legacy waivers match by exact normalized ID or email", () => { + const records = [ + { Name: "Raymmah Grandy Garcia", Email: "rag002@ucsd.edu", Date_Signed: "2026-08-17", A_Number: "A53258193" }, + { Name: "Maria G Diaz Gonzalez", Email: "mdiazgonzalez@ucsd.edu", Date_Signed: "2026-03-09", A_Number: "A69044638" }, + ]; + const matches = core.staffLegacyWaiverMatchesFromRecords_([ + { requestId: "raymmah", identifiers: ["53258193"], email: "" }, + { requestId: "maria", identifiers: [], email: "MDIAZGONZALEZ@UCSD.EDU" }, + { requestId: "wrong", identifiers: ["A69044639"], email: "other@ucsd.edu" }, + ], records); + assert.deepEqual({ ...matches }, { raymmah: true, maria: true }); +}); + +test("Mark left immediately exposes a disabled processing state", () => { + assert.match(indexHtml, /left\.disabled=true/); + assert.match(indexHtml, /left\.setAttribute\("aria-busy","true"\)/); + assert.match(indexHtml, /left\.textContent="Marking left…"/); +}); + +test("legacy tool keys become readable approval labels", () => { + assert.equal(core.staffToolLabel_("epilog_laser_cutter"), "Laser cutter"); + assert.equal(core.staffToolLabel_("wood_shop"), "Wood Shop"); +}); + +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, ""); +}); + +test("staff profile edits validate role and student graduation", () => { + const undergraduate = core.staffValidateProfile_({ role: "Undergraduate Student (UG)", affiliation: "Marine Biology", anticipatedGraduation: "2028-06" }); + assert.equal(undergraduate.ok, true); + assert.equal(undergraduate.value.anticipatedGraduation, "2028-06"); + assert.equal(core.staffValidateProfile_({ role: "UG Student Employee", affiliation: "Marine Biology", anticipatedGraduation: "2028-06" }).ok, false); + assert.equal(core.staffValidateProfile_({ role: "Graduate Student (MS)", affiliation: "Marine Biology", anticipatedGraduation: "" }).ok, false); + assert.equal(core.staffValidateProfile_({ role: "Graduate Student (PhD)", affiliation: "Marine Biology", anticipatedGraduation: "2030-06" }).ok, true); + assert.equal(core.staffValidateProfile_({ role: "Graduate Student MS, PhD", affiliation: "Marine Biology", anticipatedGraduation: "2030-06" }).ok, false); + const staff = core.staffValidateProfile_({ role: "Staff", affiliation: "SIO/DO", anticipatedGraduation: "2028-06" }); + assert.equal(staff.ok, true); + assert.equal(staff.value.anticipatedGraduation, ""); +}); diff --git a/Kiosk-v2/bridge/README.md b/Kiosk-v2/bridge/README.md index 2c580ca..38a536c 100644 --- a/Kiosk-v2/bridge/README.md +++ b/Kiosk-v2/bridge/README.md @@ -14,9 +14,11 @@ When the kiosk UI is served from `localhost`, it automatically connects to `ws:/ The Sheets backend warms its user, waiver, and activity caches at startup. The user and waiver cache defaults to five minutes; the activity cache defaults to one hour and is updated after every successful append. Override these with `SHEETS_CACHE_SECONDS` and `SHEETS_ACTIVITY_CACHE_SECONDS` when needed. +Waiver acceptance is additive during the Scripps transition. The existing `Waiver Signatures SIO` sheet is checked first and remains sufficient indefinitely. A miss in the legacy sheet is followed by a local lookup in the production database's `Scripps Waivers` tab. The tab name defaults to `Scripps Waivers` and can be overridden with `SCRIPPS_WAIVER_TAB_NAME`; if the tab is absent, legacy checks continue normally and new-only records fail closed. + ## Designated-staff card linking -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 +30,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. Linking a new card replaces and disables every previous active card for that account. A card already assigned to any account cannot be assigned again. ## Test without hardware diff --git a/Kiosk-v2/bridge/app.py b/Kiosk-v2/bridge/app.py index 7de30f1..7efc6f1 100644 --- a/Kiosk-v2/bridge/app.py +++ b/Kiosk-v2/bridge/app.py @@ -4,6 +4,7 @@ import asyncio from contextlib import asynccontextmanager +from dataclasses import replace from datetime import datetime, timezone import logging import os @@ -17,6 +18,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 @@ -28,27 +30,30 @@ BACKEND_MODE = os.getenv("SCANNER_CHECKIN_BACKEND", "demo").strip().lower() DUPLICATE_WINDOW_SECONDS = float(os.getenv("SCANNER_DUPLICATE_SECONDS", "15")) CARD_LINK_SESSION_SECONDS = float(os.getenv("CARD_LINK_SESSION_SECONDS", "300")) +PROFILE_SESSION_SECONDS = float(os.getenv("PROFILE_SESSION_SECONDS", "300")) DESIGNATED_CARD_LINK_STAFF_IDS = { value.strip() 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" @@ -61,6 +66,13 @@ def __init__( self.pending_card_uid: str | None = None self.pending_card_expires_at = 0.0 self.card_link_identifier: str | None = None + self.group_link_request: dict[str, Any] | None = None + self.profile_person_id: str | None = None + self.profile_expires_at = 0.0 + self.last_success_uid: str | None = None + self.last_success_result: CheckInResult | None = None + self.last_success_at = 0.0 + self.resume_uid_once: str | None = None self.designated_card_link_staff_ids = ( DESIGNATED_CARD_LINK_STAFF_IDS if designated_card_link_staff_ids is None @@ -80,6 +92,35 @@ def card_link_is_active(self) -> bool: return False return True + def start_profile_session(self, result: CheckInResult) -> None: + if result.outcome == "success" and result.person_id: + self.profile_person_id = result.person_id + self.profile_expires_at = time.monotonic() + PROFILE_SESSION_SECONDS + else: + self.clear_profile_session() + + def clear_profile_session(self) -> None: + self.profile_person_id = None + self.profile_expires_at = 0.0 + + def profile_session_is_active(self) -> bool: + if not self.profile_person_id or time.monotonic() >= self.profile_expires_at: + self.clear_profile_session() + return False + return True + + def allow_intentional_repeat(self) -> bool: + """Permit one fresh read, resuming a recent successful profile without logging twice.""" + self.guard.clear() + can_resume = ( + self.last_success_uid is not None + and self.last_success_result is not None + and self.profile_session_is_active() + and time.monotonic() - self.last_success_at < PROFILE_SESSION_SECONDS + ) + self.resume_uid_once = self.last_success_uid if can_resume else None + return can_resume + def broadcast(self, event: dict[str, Any]) -> None: for client in tuple(self.clients): if client.full(): @@ -110,6 +151,12 @@ async def publish(self, uid: str) -> bool: len(self.clients), ) + resume_profile = ( + uid == self.resume_uid_once + and self.last_success_result is not None + and self.profile_session_is_active() + ) + self.resume_uid_once = None expired_card_link = ( self.backend is not None and self.card_link_identifier is not None @@ -120,7 +167,23 @@ async def publish(self, uid: str) -> bool: and self.card_link_identifier is not None and self.card_link_is_active() ) - if expired_card_link: + group_link_request = self.group_link_request if self.card_link_identifier is None else None + if group_link_request: + try: + result = await asyncio.to_thread(self.backend.complete_group_link, group_link_request, uid) + except Exception: + LOGGER.exception("Group-onboarding card connection failed") + result = CheckInResult(outcome="group_link_error", message="The card could not be connected. Ask staff to try again.") + if result.outcome == "group_link_error": + self.guard.forget(uid) + else: + self.group_link_request = None + elif resume_profile: + result = replace( + self.last_success_result, + message="Your check-in was already recorded. Continue your profile update.", + ) + elif expired_card_link: result = CheckInResult( outcome="card_link_error", message="The card-link session expired. Scan the member card again.", @@ -173,6 +236,11 @@ async def publish(self, uid: str) -> bool: elif self.backend is not None: self.backend_ready = True + if result.outcome == "success" and not resume_profile: + self.last_success_uid = uid + self.last_success_result = result + self.last_success_at = time.monotonic() + processing_ms = round( (asyncio.get_running_loop().time() - started_at) * 1000 ) @@ -182,11 +250,14 @@ async def publish(self, uid: str) -> bool: "display_name": result.display_name, "message": result.message, "visit_count": result.visit_count, + "person_id": result.person_id, + "profile": result.profile, "read_at": read_at, "sequence": sequence, "processing_ms": processing_ms, "backend_timings_ms": result.timings_ms, } + self.start_profile_session(result) self.broadcast(event) LOGGER.info( "Card read processed with outcome %s in %sms; backend stages=%s", @@ -246,10 +317,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 +352,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, } @@ -327,11 +398,20 @@ class CardLinkStart(BaseModel): identifier: str +class GroupLinkCancel(BaseModel): + request_id: str + + +class ProfileAnswer(BaseModel): + field: str + value: str + + @app.post("/card-link/start") 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: @@ -368,11 +448,55 @@ async def cancel_card_link() -> dict[str, bool]: return {"ok": True} +@app.get("/group-link/status") +async def group_link_status() -> dict[str, Any]: + if not isinstance(STATE.backend, SheetsCheckInBackend): + return {"ok": True, "active": False} + try: + request = await asyncio.to_thread(STATE.backend.pending_group_link_request) + except Exception: + LOGGER.exception("Group-onboarding status check failed") + raise HTTPException(status_code=503, detail="The group-onboarding queue could not be checked") + if not request: + STATE.group_link_request = None + return {"ok": True, "active": False} + STATE.group_link_request = request + return { + "ok": True, + "active": True, + "request_id": request.get("request_id"), + "display_name": request.get("display_name"), + "expires_at": request.get("expires_at"), + } + + +@app.post("/group-link/cancel") +async def cancel_group_link(request: GroupLinkCancel) -> dict[str, bool]: + if not isinstance(STATE.backend, SheetsCheckInBackend): + raise HTTPException(status_code=409, detail="Group onboarding is unavailable") + request_id = request.request_id.strip() + if not request_id: + raise HTTPException(status_code=422, detail="A request ID is required") + try: + await asyncio.to_thread(STATE.backend.cancel_group_link_request, request_id) + except Exception: + LOGGER.exception("Group-onboarding cancellation failed") + raise HTTPException(status_code=503, detail="The request could not be cancelled") + if STATE.group_link_request and STATE.group_link_request.get("request_id") == request_id: + STATE.group_link_request = None + return {"ok": True} + + +@app.post("/scanner/allow-repeat") +async def allow_repeat() -> dict[str, bool]: + return {"ok": True, "will_resume_profile": STATE.allow_intentional_repeat()} + + @app.post("/check-in/identifier") 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() @@ -407,12 +531,15 @@ async def check_in_with_identifier(read: IdentifierCheckIn) -> dict[str, Any]: processing_ms, result.timings_ms, ) + STATE.start_profile_session(result) return { "type": "card_read", "outcome": result.outcome, "display_name": result.display_name, "message": result.message, "visit_count": result.visit_count, + "person_id": result.person_id, + "profile": result.profile, "read_at": read_at, "sequence": sequence, "processing_ms": processing_ms, @@ -420,6 +547,41 @@ async def check_in_with_identifier(read: IdentifierCheckIn) -> dict[str, Any]: } +@app.post("/profile") +async def update_profile(answer: ProfileAnswer) -> dict[str, Any]: + field = answer.field.strip() + value = answer.value.strip() + if field not in {"role", "affiliation", "anticipatedGraduation"}: + raise HTTPException(status_code=422, detail="That profile field cannot be updated") + if not value or len(value) > 120: + raise HTTPException(status_code=422, detail="Enter a valid answer") + if STATE.backend is None: + raise HTTPException(status_code=409, detail="Profile updates are unavailable in demo mode") + if not STATE.profile_session_is_active() or not STATE.profile_person_id: + raise HTTPException(status_code=409, detail="This profile session expired. Check in again.") + try: + result = await asyncio.to_thread( + STATE.backend.update_profile, + STATE.profile_person_id, + field, + value, + ) + except Exception: + LOGGER.exception("Profile update failed") + raise HTTPException(status_code=503, detail="That answer could not be saved") + if result.outcome != "profile_updated": + raise HTTPException(status_code=409, detail=result.message or "That answer could not be saved") + STATE.profile_expires_at = time.monotonic() + PROFILE_SESSION_SECONDS + if STATE.last_success_result and STATE.last_success_result.person_id == STATE.profile_person_id: + STATE.last_success_result = replace(STATE.last_success_result, profile=result.profile) + return { + "ok": True, + "outcome": result.outcome, + "message": result.message, + "profile": result.profile, + } + + @app.post("/simulate") async def simulate_card_read(read: SimulatedRead) -> dict[str, bool]: if not SIMULATION_ENABLED: diff --git a/Kiosk-v2/bridge/apps_script_backend.py b/Kiosk-v2/bridge/apps_script_backend.py new file mode 100644 index 0000000..e58d379 --- /dev/null +++ b/Kiosk-v2/bridge/apps_script_backend.py @@ -0,0 +1,154 @@ +"""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"), + person_id=payload.get("personId"), + profile={ + "role": str((payload.get("profile") or {}).get("role") or ""), + "affiliation": str((payload.get("profile") or {}).get("affiliation") or ""), + "anticipatedGraduation": str((payload.get("profile") or {}).get("anticipatedGraduation") or ""), + }, + 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 update_profile(self, person_id: str, field: str, value: str) -> CheckInResult: + payload, request_ms = self._request( + "update_profile", + personId=person_id, + field=field, + value=value, + ) + 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/scanner_protocol.py b/Kiosk-v2/bridge/scanner_protocol.py index 405a888..e276964 100644 --- a/Kiosk-v2/bridge/scanner_protocol.py +++ b/Kiosk-v2/bridge/scanner_protocol.py @@ -40,3 +40,8 @@ def forget(self, uid: str) -> None: if uid == self._last_uid: self._last_uid = None self._last_seen = 0.0 + + def clear(self) -> None: + """Allow the next card read after an intentional return to the start.""" + self._last_uid = None + self._last_seen = 0.0 diff --git a/Kiosk-v2/bridge/sheets_backend.py b/Kiosk-v2/bridge/sheets_backend.py index 60f7799..b9352bc 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") @@ -24,19 +29,22 @@ class CheckInResult: display_name: str | None = None message: str = "" visit_count: int | None = None + person_id: str | None = None + profile: dict[str, str] = field(default_factory=dict) timings_ms: dict[str, int] = field(default_factory=dict) 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 update_user_card_by_person(self, person_id: str, card_uid: str) -> dict[str, Any]: ... + def pending_group_link_request(self) -> dict[str, Any] | None: ... + def mark_group_link_request(self, request_id: str, status: str, message: str) -> None: ... + def update_profile(self, person_id: str, field: str, value: str) -> dict[str, str]: ... + def card_digest(self, card_uid: str) -> str: ... def normalize_person_id(value: Any) -> str: @@ -48,34 +56,97 @@ 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 _sheet_datetime(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError: + pass + for pattern in ("%m/%d/%Y %H:%M:%S", "%m/%d/%Y %I:%M:%S %p", "%m/%d/%Y %H:%M"): + try: + return datetime.strptime(text, pattern) + except ValueError: + continue + return None + + +def normalized_user_identifiers(record: dict[str, Any]) -> set[str]: + values = record.get("Identifiers") or [record.get("Student ID")] + if isinstance(values, str): + values = [values] + return {normalized for value in values if (normalized := normalize_person_id(value))} + + +def normalized_card_digests(record: dict[str, Any]) -> set[str]: + values = record.get("Card Digests") or [record.get("Card Digest")] + if isinstance(values, str): + values = [values] + return {str(value).strip().lower() for value in values if str(value).strip()} + + +def user_has_card_digest(record: dict[str, Any], digest: str) -> bool: + return str(digest).strip().lower() in normalized_card_digests(record) + + +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, + scripps_waiver_tab_name: str = "Scripps Waivers", ) -> 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.scripps_waiver_tab_name = scripps_waiver_tab_name self._lock = Lock() - self._user_sheet: Any = None + self._people_sheet: Any = None + self._database: Any = None + self._identifiers_sheet: Any = None + self._cards_sheet: Any = None + self._registrations_sheet: Any = None + self._visits_sheet: Any = None self._waiver_sheet: Any = None - self._activity_sheet: Any = None + self._scripps_waiver_sheet: Any = None self._users: list[dict[str, Any]] | None = None self._waivers: list[dict[str, Any]] | None = None + self._scripps_waivers: list[dict[str, Any]] | None = None self._activity_rows: list[list[Any]] | None = None self._cache_expires_at = 0.0 self._activity_cache_expires_at = 0.0 @@ -83,65 +154,138 @@ 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")), + scripps_waiver_tab_name=os.getenv("SCRIPPS_WAIVER_TAB_NAME", "Scripps Waivers").strip(), + ) + + def additional_waiver_found(self, user: dict[str, Any]) -> bool: + self._refresh_people_if_needed() + user_ids = normalized_user_identifiers(user) + user_email = normalize_email(user.get("Email Address")) + return any( + str(record.get("Status", "")).strip().lower() == "completed" + and ( + normalize_person_id(record.get("Normalized Identifier") or record.get("Participant ID")) in user_ids + or (bool(user_email) and normalize_email(record.get("Participant Email")) == user_email) + ) + for record in self._scripps_waivers or [] ) + 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._database = database + self._people_sheet = database.worksheet("People") + self._identifiers_sheet = database.worksheet("Identifiers") + self._cards_sheet = database.worksheet("Cards") + self._registrations_sheet = database.worksheet("Registrations") + 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 + try: + self._scripps_waiver_sheet = database.worksheet(self.scripps_waiver_tab_name) + except gspread.WorksheetNotFound: + self._scripps_waiver_sheet = None def _refresh_people_if_needed(self) -> None: now = time.monotonic() 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"]) + registrations = self._registrations_sheet.get_all_records(numericise_ignore=["all"]) + self._waivers = self._waiver_sheet.get_all_records(numericise_ignore=["all"]) + self._scripps_waivers = self._scripps_waiver_sheet.get_all_records(numericise_ignore=["all"]) if self._scripps_waiver_sheet else [] + identifiers_by_person: dict[str, list[dict[str, Any]]] = {} + cards_by_person: dict[str, list[dict[str, Any]]] = {} + for record in identifiers: + 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 {} + card_digests = tuple(dict.fromkeys( + str(record.get("Card Digest", "")).strip().lower() + for record in person_cards + if str(record.get("Card Digest", "")).strip() + )) + registration = next((record for record in reversed(registrations) if str(record.get("Person ID", "")).strip() == person_id), {}) + 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(), + "Card Digests": card_digests, + "Role": str(person.get("Role", "")).strip(), + "Profile": { + "role": str(person.get("Role", "")).strip(), + "affiliation": str(registration.get("Program / Department", "")).strip(), + "anticipatedGraduation": str(registration.get("Anticipated Graduation", "")).strip(), + }, + }) + 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,313 +295,400 @@ 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(user_has_card_digest(record, digest) for record in users): raise ValueError("That card is already connected to an account.") + record = matches[0] + self._connect() + headers = self._cards_sheet.row_values(1) + if "Disabled At" not in headers: + self._cards_sheet.update_cell(1, len(headers) + 1, "Disabled At") + headers.append("Disabled At") + person_column = headers.index("Person ID") + status_column = headers.index("Status") + disabled_at_column = headers.index("Disabled At") + rows = self._cards_sheet.get_all_values() + replaced_rows = [ + row_number + for row_number, row in enumerate(rows[1:], start=2) + if len(row) > max(person_column, status_column) + and str(row[person_column]).strip() == str(record["Person ID"]).strip() + and str(row[status_column]).strip().lower() == "active" + ] + changed_at = datetime.now().isoformat(timespec="seconds") + card_values = { + "Card ID": "card_" + uuid4().hex, + "Person ID": record["Person ID"], + "Card Digest": digest, + "Last Four": normalized_uid[-4:], + "Status": "Active", + "Linked At": changed_at, + "Disabled At": "", + "Source": "Kiosk v2 staff replacement", + "Notes": f"Replaced {len(replaced_rows)} previous active card(s)", + } + self._cards_sheet.append_row( + [card_values.get(header, "") for header in headers], + value_input_option="USER_ENTERED", + ) + for row_number in replaced_rows: + self._cards_sheet.update_cell(row_number, status_column + 1, "Replaced") + self._cards_sheet.update_cell(row_number, disabled_at_column + 1, changed_at) + record["Card Digest"] = digest + record["Card Digests"] = (digest,) + record["Replaced Card Count"] = len(replaced_rows) + self._cache_expires_at = time.monotonic() + self.cache_seconds + return dict(record) - index, record = matches[0] - existing_uid = str(record.get("Card UUID", "")).strip() - if existing_uid: - raise ValueError("That account already has a connected card.") + def update_user_card_by_person(self, person_id: str, card_uid: str) -> dict[str, Any]: + digest = self.card_digest(card_uid) + normalized_uid = normalize_card_uid(card_uid) + with self._lock: + self._refresh_people_if_needed() + users = self._users or [] + record = next((user for user in users if str(user.get("Person ID", "")).strip() == str(person_id).strip()), None) + if record is None: + raise ValueError("That active Sandbox account could not be found.") + if normalized_card_digests(record): + raise ValueError("This account already has an active card. Use the replacement-card workflow instead.") + if any(user_has_card_digest(user, digest) for user in users): + raise ValueError("That card is already connected to an account.") + self._connect() + headers = self._cards_sheet.row_values(1) + changed_at = datetime.now().isoformat(timespec="seconds") + card_values = { + "Card ID": "card_" + uuid4().hex, + "Person ID": record["Person ID"], + "Card Digest": digest, + "Last Four": normalized_uid[-4:], + "Status": "Active", + "Linked At": changed_at, + "Disabled At": "", + "Source": "Kiosk v2 group onboarding", + "Notes": "First card connected from Staff Desk queue", + } + self._cards_sheet.append_row([card_values.get(header, "") for header in headers], value_input_option="USER_ENTERED") + record["Card Digest"] = digest + record["Card Digests"] = (digest,) + self._cache_expires_at = time.monotonic() + self.cache_seconds + return dict(record) + def pending_group_link_request(self) -> dict[str, Any] | None: + with self._lock: self._connect() - 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 + sheet = self._database.worksheet("Kiosk Link Requests") + except Exception: + return None + records = sheet.get_all_records(numericise_ignore=["all"]) + now = datetime.now() + for record in reversed(records): + if str(record.get("Status", "")).strip().lower() != "pending": + continue + expires_at = _sheet_datetime(record.get("Expires At")) + if expires_at is not None and expires_at <= now: + continue + return { + "request_id": str(record.get("Request ID", "")).strip(), + "person_id": str(record.get("Person ID", "")).strip(), + "display_name": str(record.get("Display Name", "")).strip(), + "requested_by": str(record.get("Requested By", "")).strip(), + "expires_at": expires_at.isoformat(timespec="seconds") if expires_at else "", + } + return None + + def mark_group_link_request(self, request_id: str, status: str, message: str) -> None: + with self._lock: + self._connect() + sheet = self._database.worksheet("Kiosk Link Requests") + headers = sheet.row_values(1) + rows = sheet.get_all_values() + id_column = headers.index("Request ID") + row_number = next((index + 1 for index in range(len(rows) - 1, 0, -1) if len(rows[index]) > id_column and str(rows[index][id_column]).strip() == request_id), 0) + if not row_number: + raise ValueError("That kiosk request could not be found.") + updates = { + "Status": status, + "Completed At": datetime.now().isoformat(timespec="seconds") if status.lower() != "pending" else "", + "Message": message, + } + for header, value in updates.items(): + sheet.update_cell(row_number, headers.index(header) + 1, value) + + def update_profile(self, person_id: str, field: str, value: str) -> dict[str, str]: + field_headers = { + "role": ("people", "Role"), + "affiliation": ("registrations", "Program / Department"), + "anticipatedGraduation": ("registrations", "Anticipated Graduation"), + } + if field not in field_headers: + raise ValueError("That profile field cannot be updated.") + safe_value = str(value).strip() + if not safe_value or len(safe_value) > 120: + raise ValueError("That profile answer is not valid.") + if safe_value.startswith(("=", "+", "-", "@")): + safe_value = "'" + safe_value + + with self._lock: + self._refresh_people_if_needed() + user = next((record for record in self._users or [] if record.get("Person ID") == person_id), None) + if user is None: + raise ValueError("That active Sandbox account could not be found.") + profile = dict(user.get("Profile") or {}) + current = str(profile.get(field, "")).strip() + if current == value: + return profile + + sheet_name, header = field_headers[field] + sheet = self._people_sheet if sheet_name == "people" else self._registrations_sheet + headers = sheet.row_values(1) + person_column = headers.index("Person ID") + target_column = headers.index(header) + rows = sheet.get_all_values() + row_number = next((index + 1 for index in range(len(rows) - 1, 0, -1) if str(rows[index][person_column]).strip() == person_id), 0) + if not row_number and sheet_name == "registrations": + now = datetime.now().isoformat(timespec="seconds") + named = { + "Registration ID": "registration_" + uuid4().hex, + "Person ID": person_id, + "Status": "Profile enrichment", + "Submitted At": now, + "Consent Version": "2026-08-11", + "Source": "Kiosk profile enrichment", + } + sheet.append_row([named.get(column, "") for column in headers], value_input_option="USER_ENTERED") + row_number = len(rows) + 1 + if not row_number: + raise ValueError("That active Sandbox account could not be found.") + sheet.update_cell(row_number, target_column + 1, safe_value) + if field == "role": + updated_at = headers.index("Updated At") + sheet.update_cell(row_number, updated_at + 1, datetime.now().isoformat(timespec="seconds")) + user["Role"] = value + if current and current != value: + registration_headers = self._registrations_sheet.row_values(1) + registration_rows = self._registrations_sheet.get_all_values() + registration_person_column = registration_headers.index("Person ID") + registration_row = next((index + 1 for index in range(len(registration_rows) - 1, 0, -1) if str(registration_rows[index][registration_person_column]).strip() == person_id), 0) + if registration_row: + for dependent_header in ("Program / Department", "Anticipated Graduation"): + self._registrations_sheet.update_cell(registration_row, registration_headers.index(dependent_header) + 1, "") + profile["affiliation"] = "" + profile["anticipatedGraduation"] = "" + profile[field] = value + user["Profile"] = profile self._cache_expires_at = time.monotonic() + self.cache_seconds - return dict(record) + return dict(profile) 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 user_has_card_digest(record, 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.") + + def update_profile(self, person_id: str, field: str, value: str) -> CheckInResult: + try: + profile = self.provider.update_profile(person_id, field, value) + except ValueError as error: + return CheckInResult(outcome="profile_error", person_id=person_id, message=str(error)) return CheckInResult( - outcome="link_ready", - display_name=str(target.get("Name", "")).strip() or "Sandbox member", - message="Ask designated staff to tap their own card.", + outcome="profile_updated", + person_id=person_id, + profile=profile, + message="Profile updated.", ) - 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 pending_group_link_request(self) -> dict[str, Any] | None: + return self.provider.pending_group_link_request() + + def cancel_group_link_request(self, request_id: str) -> None: + self.provider.mark_group_link_request(request_id, "Cancelled", "Cancelled at the kiosk") + + def complete_group_link(self, request: dict[str, Any], card_uid: str) -> CheckInResult: + request_id = str(request.get("request_id", "")).strip() + person_id = str(request.get("person_id", "")).strip() + requested_by = str(request.get("requested_by", "")).strip() or "Authorized staff" + current = self.provider.pending_group_link_request() + if not request_id or not current or current.get("request_id") != request_id or current.get("person_id") != person_id: + return CheckInResult(outcome="group_link_error", message="That card-connection request expired. Ask staff to select the account again.") + target = next((user for user in self.provider.user_records() if str(user.get("Person ID", "")).strip() == person_id), None) + if target is None: + return CheckInResult(outcome="group_link_error", message="That active Sandbox account could not be found.") + if normalized_card_digests(target): + self.provider.mark_group_link_request(request_id, "Rejected", "Account already has an active card") + return CheckInResult(outcome="group_link_error", message="This account already has an active card. Ask staff to use the replacement-card workflow.") + if not self._waiver_found(target): + self.provider.mark_group_link_request(request_id, "Rejected", "Waiver no longer verified") + return CheckInResult(outcome="group_link_error", message="A signed waiver could not be verified. Please see staff.") + try: + linked = self.provider.update_user_card_by_person(person_id, card_uid) + except ValueError as error: + return CheckInResult(outcome="group_link_error", message=str(error)) + linked_at = self.local_datetime().isoformat(timespec="seconds") + self.provider.append_activity([ + "visit_" + uuid4().hex, person_id, linked_at, "Card Linked", requested_by, "Group onboarding", + "First card connected from Staff Desk queue", "Kiosk v2", "", + ]) + check_in = self._check_in_user(linked, time.monotonic(), {}) + if check_in.outcome != "success": + self.provider.mark_group_link_request(request_id, "Error", check_in.message or "Check-in failed after card connection") + return CheckInResult(outcome="group_link_error", display_name=check_in.display_name, message=check_in.message) + self.provider.mark_group_link_request(request_id, "Completed", "Card connected and check-in recorded") + return CheckInResult( + outcome="group_card_linked", + display_name=check_in.display_name, + message="Card connected and check-in recorded.", + visit_count=check_in.visit_count, + person_id=check_in.person_id, + profile=check_in.profile, + timings_ms=check_in.timings_ms, + ) + + def link_card(self, identifier: str, card_uid: str, staff_card_uid: str, designated_staff_ids: set[str]) -> CheckInResult: total_started = time.monotonic() timings: dict[str, int] = {} 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 user_has_card_digest(record, 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() + replaced_count = int(target.get("Replaced Card Count", 0) or 0) self.provider.append_activity([ - local_now.strftime("%m/%d/%Y %H:%M:%S"), - int(self.now()), - display_name, - normalized_card_uid, - "Card Linked", + "visit_" + uuid4().hex, + target.get("Person ID", ""), + self.local_datetime().isoformat(timespec="seconds"), + "Card Replaced" if replaced_count else "Card Linked", staff_name, "", + f"Disabled {replaced_count} previous active card(s)." if replaced_count else "", + "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 _waiver_found(self, user: dict[str, Any]) -> bool: + 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 + legacy_found = any( + (normalize_person_id(waiver.get("A_Number")) in user_ids) + or (bool(user_email) and normalize_email(waiver.get("Email")) == user_email) + for waiver in self.provider.waiver_records() ) + if legacy_found: + return True + additional_checker = getattr(self.provider, "additional_waiver_found", None) + return bool(additional_checker and additional_checker(user)) + + def _check_in_user(self, user: dict[str, Any], total_started: float, timings: dict[str, int]) -> CheckInResult: + stage_started = time.monotonic() + waiver_found = self._waiver_found(user) 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) @@ -468,5 +699,7 @@ def _check_in_user( display_name=display_name, message="Check-in recorded.", visit_count=visit_count, + person_id=person_id, + profile=dict(user.get("Profile") or {}), timings_ms=timings, ) diff --git a/Kiosk-v2/bridge/tests/test_apps_script_backend.py b/Kiosk-v2/bridge/tests/test_apps_script_backend.py new file mode 100644 index 0000000..3084c1a --- /dev/null +++ b/Kiosk-v2/bridge/tests/test_apps_script_backend.py @@ -0,0 +1,19 @@ +from apps_script_backend import AppsScriptCheckInBackend + + +def test_result_preserves_profile_context(): + result = AppsScriptCheckInBackend._result({ + "outcome": "success", + "displayName": "Test Maker", + "visitCount": 3, + "personId": "person_1", + "profile": { + "role": "Undergraduate Student (UG)", + "affiliation": "Marine Biology", + "anticipatedGraduation": "2028-06", + }, + }, 42) + assert result.person_id == "person_1" + assert result.profile["role"] == "Undergraduate Student (UG)" + assert result.profile["affiliation"] == "Marine Biology" + assert result.profile["anticipatedGraduation"] == "2028-06" diff --git a/Kiosk-v2/bridge/tests/test_bridge_events.py b/Kiosk-v2/bridge/tests/test_bridge_events.py index ca1427e..26ef453 100644 --- a/Kiosk-v2/bridge/tests/test_bridge_events.py +++ b/Kiosk-v2/bridge/tests/test_bridge_events.py @@ -17,6 +17,8 @@ def check_in(self, uid: str) -> CheckInResult: display_name="Test member", message="Check-in recorded.", visit_count=3, + person_id="person_1", + profile={"role": "", "affiliation": "", "anticipatedGraduation": ""}, ) @@ -25,6 +27,21 @@ def check_in(self, uid: str) -> CheckInResult: raise RuntimeError("temporary Sheets failure") +class CountingProfileBackend: + def __init__(self) -> None: + self.check_in_calls = 0 + + def check_in(self, uid: str) -> CheckInResult: + self.check_in_calls += 1 + return CheckInResult( + outcome="success", + display_name="Test member", + visit_count=4, + person_id="person_1", + profile={"role": "", "affiliation": "", "anticipatedGraduation": ""}, + ) + + class CardLinkBackend: def __init__(self) -> None: self.link_calls = [] @@ -65,10 +82,22 @@ async def scenario() -> None: assert completed["sequence"] == detected["sequence"] assert isinstance(completed["processing_ms"], int) assert completed["backend_timings_ms"] == {} + assert completed["person_id"] == "person_1" + assert completed["profile"]["role"] == "" + assert state.profile_session_is_active() + assert state.profile_person_id == "person_1" asyncio.run(scenario()) +def test_non_successful_read_clears_profile_session() -> None: + state = BridgeState() + state.start_profile_session(CheckInResult(outcome="success", person_id="person_1")) + assert state.profile_session_is_active() + state.start_profile_session(CheckInResult(outcome="waiver_required")) + assert not state.profile_session_is_active() + + def test_backend_failure_is_display_safe_and_allows_immediate_retry() -> None: async def scenario() -> None: state = BridgeState(FailingBackend()) @@ -87,6 +116,30 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_intentional_repeat_resumes_profile_without_second_check_in() -> None: + async def scenario() -> None: + backend = CountingProfileBackend() + state = BridgeState(backend) + queue: asyncio.Queue[dict[str, object]] = asyncio.Queue(maxsize=20) + state.clients.add(queue) + + assert await state.publish("0123456789ABCD") is True + await queue.get() + first = await queue.get() + assert first["outcome"] == "success" + assert backend.check_in_calls == 1 + + assert state.allow_intentional_repeat() is True + assert await state.publish("0123456789ABCD") is True + await queue.get() + resumed = await queue.get() + assert resumed["outcome"] == "success" + assert "already recorded" in str(resumed["message"]) + assert backend.check_in_calls == 1 + + asyncio.run(scenario()) + + def test_unknown_card_is_kept_only_for_a_short_staff_link_session() -> None: async def scenario() -> None: backend = CardLinkBackend() diff --git a/Kiosk-v2/bridge/tests/test_scanner_protocol.py b/Kiosk-v2/bridge/tests/test_scanner_protocol.py index 627541f..228edbb 100644 --- a/Kiosk-v2/bridge/tests/test_scanner_protocol.py +++ b/Kiosk-v2/bridge/tests/test_scanner_protocol.py @@ -38,6 +38,12 @@ def test_forget_allows_immediate_retry_after_backend_failure(self) -> None: guard.forget("04A1B2C3") self.assertTrue(guard.accept("04A1B2C3", now=11)) + def test_clear_allows_an_intentional_repeat(self) -> None: + guard = DuplicateGuard(window_seconds=15) + self.assertTrue(guard.accept("04A1B2C3", now=10)) + guard.clear() + self.assertTrue(guard.accept("04A1B2C3", now=11)) + if __name__ == "__main__": unittest.main() diff --git a/Kiosk-v2/bridge/tests/test_sheets_backend.py b/Kiosk-v2/bridge/tests/test_sheets_backend.py index 9bed2cb..0b82813 100644 --- a/Kiosk-v2/bridge/tests/test_sheets_backend.py +++ b/Kiosk-v2/bridge/tests/test_sheets_backend.py @@ -1,53 +1,114 @@ from datetime import datetime +import time from sheets_backend import ( GoogleSheetsProvider, SheetsCheckInBackend, normalize_person_id, + user_has_card_digest, + users_matching_identifier, ) +def test_google_provider_matches_completed_scripps_waiver_from_production_tab(): + provider = GoogleSheetsProvider("unused.json", "database-id", "Waiver Signatures SIO", "secret") + provider._users = [] + provider._cache_expires_at = time.monotonic() + 60 + provider._scripps_waivers = [ + {"Status": "completed", "Participant Email": "member@ucsd.edu", "Participant ID": "A12345678", "Normalized Identifier": "12345678"}, + {"Status": "voided", "Participant Email": "voided@ucsd.edu", "Participant ID": "A87654321", "Normalized Identifier": "87654321"}, + ] + assert provider.additional_waiver_found({"Identifiers": ["A12345678"], "Email Address": ""}) + assert provider.additional_waiver_found({"Identifiers": [], "Email Address": "MEMBER@UCSD.EDU"}) + assert not provider.additional_waiver_found({"Identifiers": ["A87654321"], "Email Address": "voided@ucsd.edu"}) + + class FakeProvider: def __init__(self, users=None, waivers=None, existing_activity=None): - self.users = 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} + self.group_request = None + self.group_updates = [] + self.additional_waiver = False + + @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 additional_waiver_found(self, user): + del user + return self.additional_waiver 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(user_has_card_digest(row, 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] + replaced_count = len(matches[0].get("Card Digests") or []) + matches[0]["Card Digest"] = digest + matches[0]["Card Digests"] = (digest,) + matches[0]["Replaced Card Count"] = replaced_count + return dict(matches[0]) + + def update_user_card_by_person(self, person_id, card_uid): + target = next((row for row in self.users if row.get("Person ID") == person_id), None) + if not target: + raise ValueError("That active Sandbox account could not be found.") + if target.get("Card Digests"): + raise ValueError("This account already has an active card. Use the replacement-card workflow instead.") + digest = self.card_digest(card_uid) + if any(user_has_card_digest(row, digest) for row in self.users): + raise ValueError("That card is already connected to an account.") + target["Card Digest"] = digest + target["Card Digests"] = (digest,) + return dict(target) + + def pending_group_link_request(self): + return dict(self.group_request) if self.group_request else None + + def mark_group_link_request(self, request_id, status, message): + self.group_updates.append((request_id, status, message)) + if status.lower() != "pending": + self.group_request = None + + def update_profile(self, person_id, field, value): + user = next((row for row in self.users if row.get("Person ID") == person_id), None) + if not user: + raise ValueError("That active Sandbox account could not be found.") + profile = dict(user.get("Profile") or {}) + if field == "role" and profile.get("role") and profile["role"] != value: + profile["affiliation"] = "" + profile["anticipatedGraduation"] = "" + profile[field] = value + user["Profile"] = profile + return profile def backend_for(provider): @@ -58,284 +119,339 @@ def backend_for(provider): ) -class FakeActivitySheet: +def member(card="CARD123", person_id="person_1", student_id="A12345678", email="maker@example.com", identifiers=None): + card_digest = FakeProvider.card_digest(card) if card else "" + return { + "Person ID": person_id, + "Name": "Test Maker", + "Student ID": student_id, + "Identifiers": identifiers or [student_id], + "Email Address": email, + "Card Digest": card_digest, + "Card Digests": (card_digest,) if card_digest else (), + "Profile": {"role": "", "affiliation": "", "anticipatedGraduation": ""}, + } + + +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 = [] +class FakeCardsSheet: + HEADERS = ["Card ID", "Person ID", "Card Digest", "Last Four", "Status", "Linked At", "Disabled At", "Source", "Notes"] - def get_all_records(self, numericise_ignore=None): - return self.records + def __init__(self, rows=None, headers=None): + self.appends = [] + self.rows = [list(headers or self.HEADERS), *(list(row) for row in (rows or []))] - def row_values(self, row): - assert row == 1 - return ["Name", "Student ID", "Card UUID"] + def row_values(self, row_number): + return list(self.rows[row_number - 1]) - def update_cell(self, row, column, value): - self.updated_cells.append((row, column, value)) + def get_all_values(self): + return [list(row) for row in self.rows] + def append_row(self, row, value_input_option=None): + self.appends.append((list(row), value_input_option)) + self.rows.append(list(row)) -def test_activity_cache_avoids_full_sheet_read_after_append(): - sheet = FakeActivitySheet() - provider = GoogleSheetsProvider("credentials", "users", "waivers", "activity") - provider._activity_sheet = sheet + def update_cell(self, row_number, column_number, value): + while len(self.rows[row_number - 1]) < column_number: + self.rows[row_number - 1].append("") + self.rows[row_number - 1][column_number - 1] = value + +def test_activity_cache_avoids_full_sheet_read_after_append(): + 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() - - updated = provider.update_user_card("12345678", "newcard123456") - - assert user_sheet.updated_cells == [(3, 3, "NEWCARD123456")] - assert updated["Card UUID"] == "NEWCARD123456" + assert len(sheet.appends) == 1 + + +def test_google_sheets_provider_replaces_the_previous_active_card(): + provider = GoogleSheetsProvider("credentials", "database", "waivers", "long-enough-test-secret") + provider._visits_sheet = FakeVisitsSheet() + existing = member(card="", person_id="person_2") + existing_digest = provider.card_digest("OLDCARD") + existing["Card Digest"] = existing_digest + existing["Card Digests"] = (existing_digest,) + provider._cards_sheet = FakeCardsSheet([[ + "card_old", "person_2", existing_digest, "1234", "Active", + "2026-08-13T09:00:00", "", "Registration", "", + ]]) + provider._users = [existing] + 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 updated["Card Digests"] == (provider.card_digest("ABCDEF12345678"),) + assert updated["Replaced Card Count"] == 1 + assert provider._cards_sheet.rows[1][4] == "Replaced" + assert provider._cards_sheet.rows[1][6] + 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_google_sheets_provider_upgrades_legacy_cards_sheet_before_replacement(): + legacy_headers = ["Card ID", "Person ID", "Card Digest", "Last Four", "Status", "Linked At", "Source", "Notes"] + provider = GoogleSheetsProvider("credentials", "database", "waivers", "long-enough-test-secret") + existing = member(card="", person_id="person_2") + existing_digest = provider.card_digest("OLDCARD") + existing["Card Digest"] = existing_digest + existing["Card Digests"] = (existing_digest,) + provider._cards_sheet = FakeCardsSheet([[ + "card_old", "person_2", existing_digest, "1234", "Active", + "2026-08-13T09:00:00", "Registration", "", + ]], headers=legacy_headers) + provider._visits_sheet = FakeVisitsSheet() + provider._users = [existing] + provider._cache_expires_at = float("inf") + + updated = provider.update_user_card("12345678", "ABCDEF12345678") + + headers = provider._cards_sheet.rows[0] + appended, _ = provider._cards_sheet.appends[0] + assert headers[-1] == "Disabled At" + assert provider._cards_sheet.rows[1][headers.index("Status")] == "Replaced" + assert provider._cards_sheet.rows[1][headers.index("Disabled At")] + assert appended[headers.index("Source")] == "Kiosk v2 staff replacement" + assert appended[headers.index("Notes")] == "Replaced 1 previous active card(s)" + assert updated["Replaced Card Count"] == 1 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 result.person_id == "person_1" + assert result.profile == {"role": "", "affiliation": "", "anticipatedGraduation": ""} + 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_profile_answers_can_be_corrected_during_the_active_session(): + provider = FakeProvider(users=[member()]) + backend = backend_for(provider) + first = backend.update_profile("person_1", "role", "Undergraduate Student (UG)") + assert first.outcome == "profile_updated" + assert first.profile["role"] == "Undergraduate Student (UG)" + corrected = backend.update_profile("person_1", "role", "Staff") + assert corrected.outcome == "profile_updated" + assert corrected.profile == {"role": "Staff", "affiliation": "", "anticipatedGraduation": ""} 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=[], - ) + provider = FakeProvider(users=[member()], waivers=[]) + result = backend_for(provider).check_in("CARD123") + assert result.outcome == "waiver_required" + assert provider.calls["activity"] == 0 + assert provider.calls["append"] == 0 - result = backend_for(provider).check_in("ABCDEF12345678") - assert result.outcome == "waiver_required" - assert provider.calls == {"users": 1, "waivers": 1, "activity": 0, "append": 0, "card_update": 0} - assert provider.appended_rows == [] +def test_new_scripps_waiver_is_accepted_when_legacy_sheet_has_no_match(): + provider = FakeProvider(users=[member()], waivers=[]) + provider.additional_waiver = True + result = backend_for(provider).check_in("CARD123") + assert result.outcome == "success" + assert provider.calls["append"] == 1 -def test_email_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"}], - ) +def test_legacy_waiver_still_succeeds_without_calling_new_source(): + provider = FakeProvider(users=[member()], waivers=[signed_waiver()]) + provider.additional_waiver = True + calls = {"new": 0} + def new_source(user): + del user + calls["new"] += 1 + return True + provider.additional_waiver_found = new_source + assert backend_for(provider).check_in("CARD123").outcome == "success" + assert calls["new"] == 0 - result = backend_for(provider).check_in("ABCDEF12345678") - assert result.outcome == "success" - assert len(provider.appended_rows) == 1 +def test_email_waiver_match_is_case_insensitive(): + provider = FakeProvider( + 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" -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) + 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_replaces_the_existing_card_and_disables_the_first(): + 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]) backend = backend_for(provider) + result = backend.link_card("A12345678", "NEWCARD", "STAFFCARD", {"A87654321"}) + assert result.outcome == "card_linked" + assert backend.check_in("OLDCARD").outcome == "unknown_card" + assert backend.check_in("NEWCARD").outcome == "waiver_required" + assert provider.calls["append"] == 1 + assert provider.appended_rows[0][3] == "Card Replaced" + assert "Disabled 1 previous active card" in provider.appended_rows[0][6] + + +def test_prepare_card_link_allows_an_account_with_an_existing_card(): + target = member(card="OLDCARD", person_id="person_member", student_id="999222") + provider = FakeProvider(users=[target]) + result = backend_for(provider).prepare_card_link("999222") + assert result.outcome == "link_ready" + assert result.display_name == "Test Maker" - 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) +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 + - assert backend.prepare_card_link("A99999999").outcome == "unknown_identifier" - assert backend.prepare_card_link("A12345678").outcome == "card_link_error" +def test_group_onboarding_connects_first_card_and_checks_member_in(): + target = member(card="", person_id="person_member", student_id="A12345678") + provider = FakeProvider(users=[target], waivers=[signed_waiver()]) + provider.group_request = { + "request_id": "link_1", "person_id": "person_member", "display_name": "Test Maker", + "requested_by": "staff@example.edu", "expires_at": "2026-08-14T15:00:00", + } + result = backend_for(provider).complete_group_link(provider.group_request, "NEWCARD") + assert result.outcome == "group_card_linked" + assert result.display_name == "Test Maker" + assert [row[3] for row in provider.appended_rows] == ["Card Linked", "User Checkin"] + assert provider.group_updates[-1][1] == "Completed" + assert user_has_card_digest(provider.users[0], provider.card_digest("NEWCARD")) + + +def test_group_onboarding_refuses_an_account_with_an_active_card(): + target = member(card="OLDCARD", person_id="person_member", student_id="A12345678") + provider = FakeProvider(users=[target], waivers=[signed_waiver()]) + provider.group_request = { + "request_id": "link_1", "person_id": "person_member", "display_name": "Test Maker", + "requested_by": "staff@example.edu", "expires_at": "2026-08-14T15:00:00", + } + result = backend_for(provider).complete_group_link(provider.group_request, "NEWCARD") + assert result.outcome == "group_link_error" + assert "already has an active card" in result.message + assert provider.appended_rows == [] + assert provider.group_updates[-1][1] == "Rejected" diff --git a/Kiosk-v2/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..37acbfa --- /dev/null +++ b/Kiosk-v2/deploy/pi/scanner.env.example @@ -0,0 +1,16 @@ +# 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 +SCRIPPS_WAIVER_TAB_NAME=Scripps Waivers +CARD_UID_HMAC_SECRET=REPLACE_WITH_SECRET_FROM_CREDENTIAL_REGISTER +CARD_LINK_STAFF_IDS=REPLACE_WITH_COMMA_SEPARATED_APPROVED_IDS +CARD_LINK_SESSION_SECONDS=300 + +# 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/docusign-transition.md b/Kiosk-v2/docs/docusign-transition.md new file mode 100644 index 0000000..e8e94c0 --- /dev/null +++ b/Kiosk-v2/docs/docusign-transition.md @@ -0,0 +1,61 @@ +# Scripps DocuSign waiver transition + +## Acceptance policy + +The transition is additive. A completed waiver from either source is sufficient: + +1. the existing `Waiver Signatures SIO` Google Sheet; or +2. the `Scripps Waivers` tab in `Scripps Sandbox Database v2 — Production`. + +Legacy completions remain valid indefinitely. The Scripps integration never +writes to or invalidates the old sheet. + +## Google-only data flow + +DocuSign Connect sends completed-envelope JSON to the UC San Diego-owned +registration Apps Script web app. The handler validates an unguessable callback +token and, when configured, the exact DocuSign template ID. It records only the +minimum matching data in the production spreadsheet: receipt time, envelope ID, +status, completion time, participant name and email, optional UC San Diego ID, +normalized ID, template ID, and source. + +Duplicate Connect deliveries are safe: envelope ID is unique and a repeated +event does not add a second row. The kiosk and Staff Desk read the protected +Google Sheet directly; they do not call a separate public status service. + +## Required configuration + +- Registration Apps Script property: `DOCUSIGN_CONNECT_TOKEN` +- Registration Apps Script property: `DOCUSIGN_WAIVER_TEMPLATE_ID` +- Production spreadsheet tab: `Scripps Waivers` +- Optional Pi setting: `SCRIPPS_WAIVER_TAB_NAME=Scripps Waivers` +- DocuSign Connect destination: + `REGISTRATION_WEB_APP_URL?waiver_key=DOCUSIGN_CONNECT_TOKEN` + +Do not place the callback URL or token in source control, staff documentation, +QR codes, or public pages. + +Google Apps Script exposes query parameters and POST bodies to `doPost`, but it +does not expose arbitrary request headers. Therefore this design uses a strong +callback token plus template filtering rather than DocuSign's HMAC header. If +UC San Diego requires HMAC verification for this workflow, use a UCSD-owned +Google Cloud Function or Cloud Run receiver instead; the Sheet schema and kiosk +lookups do not need to change. + +## DocuSign administrator request + +Ask the DocuSign administrator to send completed-envelope JSON for the Sandbox +Web Form/template to the private callback URL we provide, including recipient +and form-field data. Scope the configuration to the Sandbox template or form +when possible. + +## Acceptance test + +Verify all three cases before changing the public waiver link: + +1. A legacy-only signer checks in successfully. +2. A new Scripps-form signer checks in successfully after a completed event + creates one row in `Scripps Waivers`. +3. A person with neither waiver sees the waiver-required flow. + +Also resend the same completed event and confirm it does not create a duplicate. diff --git a/Kiosk-v2/docs/kiosk-releases.md b/Kiosk-v2/docs/kiosk-releases.md new file mode 100644 index 0000000..da1451a --- /dev/null +++ b/Kiosk-v2/docs/kiosk-releases.md @@ -0,0 +1,25 @@ +# Kiosk release history + +The revision shown in the kiosk's **Staff** panel comes from +`lib/kiosk-release.ts`. Update that file and add a row here for every kiosk +deployment that changes the interface or check-in behavior. + +Revision format: `YYYY.MM.DD.N`, where `N` starts at `1` and increases when +more than one revision is released on the same day. + +| Revision | Date | What changed | +| --- | --- | --- | +| 2026.08.17.2 | August 17, 2026 | The Scripps-owned waiver transition now stays in UC San Diego Google services: DocuSign completion events write to the protected production spreadsheet, and both Staff Desk and kiosk read that tab directly. | +| 2026.08.17.1 | August 17, 2026 | Existing legacy waivers remain sufficient while completed waivers from the new Scripps-owned DocuSign form are accepted as a second source. New-source failures cannot invalidate a legacy match. | +| 2026.08.14.12 | August 14, 2026 | Staff can open a newest-first queue of registered, waiver-verified members without active cards, select a member, and have the kiosk prompt that person by name to tap. The first card is connected and the visit is checked in together, with expiry, cancellation, revalidation, and an audit trail. | +| 2026.08.14.11 | August 14, 2026 | Graduate students now choose one concise graduate-program answer. Applied Ocean Science records SIO, ECE, or MAE in that answer, and common interdisciplinary programs are represented without adding another screen. | +| 2026.08.14.10 | August 14, 2026 | Master's and doctoral students are recorded as separate roles; the profile grid no longer shows a dark empty cell; staff can edit a member's role, affiliation, and graduation details from the Staff Desk. | +| 2026.08.14.9 | August 14, 2026 | Profile dialogs are smaller, previous-question navigation consistently says Back, and UG Student Employee is consolidated into Undergraduate Student (UG) on the kiosk and registration form. | +| 2026.08.14.8 | August 14, 2026 | Visitor pages remain quiet for 30 seconds, then show the return notice and countdown only during the final 15 seconds of the 45-second timeout. | +| 2026.08.14.7 | August 14, 2026 | Closing-soon and closed check-ins repeat the prominent status rectangle; profile questions can go back to correct earlier answers; an intentional restart can resume profile questions without recording another visit. | +| 2026.08.14.6 | August 14, 2026 | The inactivity notice uses one strict 30-second countdown, only “Stay here” restarts it, and the progress ring and number are centered correctly. | +| 2026.08.14.5 | August 14, 2026 | Waiver screens explain the processing delay; unattended visitor screens return home after 30 seconds; closing-soon and closed check-ins use unmistakable full-screen treatments; closing times expire at midnight. | +| 2026.08.14.4 | August 14, 2026 | Card replacement now adds the card-history timestamp column when upgrading an older Cards sheet and preserves the sheet's existing column order. | +| 2026.08.14.3 | August 14, 2026 | Linking a new card now disables the account's previous active card(s), records the replacement, and leaves only the new card active. | +| 2026.08.14.2 | August 14, 2026 | Existing accounts can receive additional active cards without disabling their earlier cards; cards still cannot be shared between accounts. | +| 2026.08.14.1 | August 14, 2026 | Profile questions now appear before the orange confirmation; profile prompts use a high-contrast card and improved field spacing; the Staff panel shows the active revision. | 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/kiosk-release.ts b/Kiosk-v2/lib/kiosk-release.ts new file mode 100644 index 0000000..925caf4 --- /dev/null +++ b/Kiosk-v2/lib/kiosk-release.ts @@ -0,0 +1,5 @@ +export const KIOSK_RELEASE = { + revision: "2026.08.17.2", + date: "August 17, 2026", + summary: "Google-only Scripps waiver records plus legacy acceptance", +} as const; diff --git a/Kiosk-v2/lib/profile-enrichment.ts b/Kiosk-v2/lib/profile-enrichment.ts new file mode 100644 index 0000000..3896eb8 --- /dev/null +++ b/Kiosk-v2/lib/profile-enrichment.ts @@ -0,0 +1,162 @@ +export type ProfileSnapshot = { + role: string; + affiliation: string; + anticipatedGraduation: string; +}; + +export type ProfileField = "role" | "affiliation" | "anticipatedGraduation"; + +export type ProfileQuestion = { + field: ProfileField; + eyebrow: string; + heading: string; + prompt: string; +}; + +export const PROFILE_ROLES = [ + "Academic", + "Staff", + "Postdoc", + "Graduate Student (MS)", + "Graduate Student (PhD)", + "MAS Student", + "Undergraduate Student (UG)", + "Affiliate (Retirees, Volunteers, etc.)", + "Visiting scholar or visitor", + "Community member", + "Other", +] as const; + +const UNDERGRADUATE_ROLES = new Set([ + "Undergraduate Student (UG)", + "UG Student Employee", +]); + +const GRADUATE_ROLES = new Set([ + "Graduate Student (MS)", + "Graduate Student (PhD)", + "Graduate Student MS, PhD", +]); + +export const SCRIPPS_UNITS = [ + "IOD-Biology", "CMBB-Biology", "MBRD-Biology", "GRD-Earth", "IGPP-Earth", + "CASPO-O&A", "MPL-O&A", "SIO/DO", "Birch Aquarium", "MarFac", "MSDC", + "Sea Grant", "SIO Academic Department", "SOMTS", +]; + +export const GRADUATE_PROGRAMS = [ + "Applied Ocean Science (AOS) — SIO", + "Applied Ocean Science (AOS) — ECE", + "Applied Ocean Science (AOS) — MAE", + "Climate Sciences (Scripps)", "Physical Oceanography (Scripps)", + "Geophysics (Scripps)", "Geosciences (Scripps)", + "Marine Chemistry and Geochemistry (Scripps)", + "Biological Oceanography (Scripps)", "Marine Biology (Scripps)", + "Bioinformatics and Systems Biology", "Biomedical Sciences", "Neurosciences", + "Electrical & Computer Engineering", "Mechanical & Aerospace Engineering", + "Computer Science & Engineering", "Bioengineering", "Chemical Engineering", + "NanoEngineering", "Structural Engineering", "Materials Science and Engineering", + "Biological Sciences", "Chemistry & Biochemistry", "Physics", + "Mathematics & Statistics", "Data Science", "Cognitive Science", "Public Health", + "Other UC San Diego graduate program", "External university or institution", +]; + +export const UCSD_DEPARTMENTS = [ + "Mechanical & Aerospace Engineering", "Electrical & Computer Engineering", + "Computer Science & Engineering", "Bioengineering", "Chemical & NanoEngineering", + "Structural Engineering", "Biological Sciences", "Chemistry & Biochemistry", "Physics", + "Mathematics", "Data Science", "Cognitive Science", "Environmental Systems", + "Public Health", "School of Medicine or Health Sciences", "Rady School of Management", + "School of Global Policy & Strategy", "Social Sciences", "Arts & Humanities", + "Central administration or campus services", +]; + +export const UNDERGRADUATE_MAJORS = [ + "Marine Biology", "Geosciences", "Oceanic and Atmospheric Sciences", + "Mechanical Engineering", "Aerospace Engineering", "Electrical Engineering", + "Computer Engineering", "Computer Science", "Bioengineering", "Chemical Engineering", + "NanoEngineering", "Structural Engineering", "General Biology", + "Ecology, Behavior and Evolution", "Molecular and Cell Biology", "Microbiology", + "Human Biology", "Neurobiology", "Environmental Systems", "Chemistry or Biochemistry", + "Physics", "Mathematics", "Data Science", "Cognitive Science", "Public Health", + "Social Sciences", "Arts & Humanities", "Business or Economics", "Undeclared", +]; + +export const VISITOR_ORGANIZATIONS = [ + "Scripps Oceanography", "UC San Diego – other department or unit", + "External university or institution", "Government laboratory or agency", + "Nonprofit organization", "Industry or company", + "Community member – no institutional affiliation", +]; + +export function emptyProfile(): ProfileSnapshot { + return { role: "", affiliation: "", anticipatedGraduation: "" }; +} + +export function nextProfileQuestion(profile: ProfileSnapshot): ProfileQuestion | null { + if (!profile.role.trim()) { + return profileQuestionForField("role", profile.role); + } + if (!profile.affiliation.trim()) { + return profileQuestionForField("affiliation", profile.role); + } + if (UNDERGRADUATE_ROLES.has(profile.role) && !profile.anticipatedGraduation.trim()) { + return profileQuestionForField("anticipatedGraduation", profile.role); + } + return null; +} + +export function profileQuestionForField(field: ProfileField, role: string): ProfileQuestion { + if (field === "role") { + return { + field, + eyebrow: "COMPLETE YOUR PROFILE", + heading: "What best describes you?", + prompt: "Choose your role.", + }; + } + if (field === "affiliation") { + const student = role.includes("Student"); + return { + field, + eyebrow: "ONE MORE QUESTION", + heading: student ? "What do you study?" : "Where do you work?", + prompt: affiliationLabel(role), + }; + } + return { + field, + eyebrow: "LAST QUESTION", + heading: "When do you expect to graduate?", + prompt: "Choose your anticipated graduation month and year.", + }; +} + +export function affiliationLabel(role: string): string { + if (UNDERGRADUATE_ROLES.has(role)) return "Choose your major."; + if (GRADUATE_ROLES.has(role)) return "Choose your graduate program."; + if (role === "MAS Student") return "Choose your MAS program."; + if (["Academic", "Staff", "Postdoc"].includes(role)) return "Choose your department, division, or unit."; + return "Choose your organization or affiliation."; +} + +export function affiliationOptions(role: string): string[] { + if (UNDERGRADUATE_ROLES.has(role)) return [...UNDERGRADUATE_MAJORS, "Other"]; + if (GRADUATE_ROLES.has(role)) { + return [...GRADUATE_PROGRAMS, "Other"]; + } + if (role === "MAS Student") return ["Marine Biodiversity & Conservation", "Climate Science & Policy", "Other"]; + if (["Academic", "Staff", "Postdoc"].includes(role)) return [...SCRIPPS_UNITS, ...UCSD_DEPARTMENTS, "Other"]; + return [...VISITOR_ORGANIZATIONS, "Other"]; +} + +export function normalizedProfileAnswer(field: ProfileField, value: string): string { + const cleaned = [...value] + .map((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 ? " " : character) + .join("") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned || cleaned.length > 120) return ""; + if (field === "anticipatedGraduation" && !/^\d{4}-(0[1-9]|1[0-2])$/.test(cleaned)) return ""; + return cleaned; +} 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/profile-enrichment.test.ts b/Kiosk-v2/tests/profile-enrichment.test.ts new file mode 100644 index 0000000..8d6c0df --- /dev/null +++ b/Kiosk-v2/tests/profile-enrichment.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + affiliationOptions, + emptyProfile, + nextProfileQuestion, + PROFILE_ROLES, + profileQuestionForField, + normalizedProfileAnswer, +} from "../lib/profile-enrichment.ts"; + +test("can reopen an earlier profile question for correction", () => { + const question = profileQuestionForField("role", "Undergraduate Student (UG)"); + assert.equal(question.field, "role"); + assert.equal(question.prompt, "Choose your role."); +}); + +test("offers one consolidated undergraduate role", () => { + assert.ok(PROFILE_ROLES.includes("Undergraduate Student (UG)")); + assert.equal(Array.from(PROFILE_ROLES).includes("UG Student Employee"), false); + assert.ok(affiliationOptions("UG Student Employee").includes("Marine Biology")); +}); + +test("offers separate master's and doctoral roles", () => { + const roles = Array.from(PROFILE_ROLES); + assert.ok(roles.includes("Graduate Student (MS)")); + assert.ok(roles.includes("Graduate Student (PhD)")); + assert.equal(roles.includes("Graduate Student MS, PhD"), false); +}); + +test("asks only the next missing profile question", () => { + const profile = emptyProfile(); + assert.equal(nextProfileQuestion(profile)?.field, "role"); + profile.role = "Undergraduate Student (UG)"; + assert.equal(nextProfileQuestion(profile)?.field, "affiliation"); + profile.affiliation = "Marine Biology"; + assert.equal(nextProfileQuestion(profile)?.field, "anticipatedGraduation"); + profile.anticipatedGraduation = "2028-06"; + assert.equal(nextProfileQuestion(profile), null); +}); + +test("does not ask non-undergraduates for graduation", () => { + assert.equal(nextProfileQuestion({ role: "Staff", affiliation: "SIO/DO", anticipatedGraduation: "" }), null); + assert.equal(nextProfileQuestion({ role: "Graduate Student (PhD)", affiliation: "Marine Biology", anticipatedGraduation: "" }), null); +}); + +test("offers role-appropriate choices", () => { + assert.ok(affiliationOptions("Undergraduate Student (UG)").includes("Marine Biology")); + assert.ok(affiliationOptions("Graduate Student (MS)").includes("Electrical & Computer Engineering")); + assert.ok(affiliationOptions("Graduate Student (PhD)").includes("Applied Ocean Science (AOS) — ECE")); + assert.ok(affiliationOptions("Graduate Student (PhD)").includes("Bioinformatics and Systems Biology")); + assert.ok(affiliationOptions("Staff").includes("IOD-Biology")); +}); + +test("normalizes kiosk answers", () => { + assert.equal(normalizedProfileAnswer("role", " Staff "), "Staff"); + assert.equal(normalizedProfileAnswer("anticipatedGraduation", "2028-06"), "2028-06"); + assert.equal(normalizedProfileAnswer("anticipatedGraduation", "spring 2028"), ""); +}); 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"), ""); +});