diff --git a/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md b/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md index 3b4393e..ff9d015 100644 --- a/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md +++ b/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md @@ -222,3 +222,22 @@ so the EKU and signature checks shell out to `openssl x509 -noout -text`. **Most likely causes, still unconfirmed on the device:** the *served* certificate was uploaded to Jamf instead of the *authority*; or the address the iPad uses is not in `HTTPS_HOSTNAMES` (the host `.env` was previously missing that line entirely). + +--- + +## Addendum — promote a guest to a recurring visitor + +Added **Make regular** to the On site list for any visit with a mobile number that is not already +a recurring visitor. + +`POST /admin/api/visits/:id/make-regular` copies name, company, phone, email, check type/number/ +expiry, host (as their default) and site from the visit, issues a random unique PIN via +`allocatePin(null)`, and copies the visit photo into a separate profile photo with +`copyStoredPhoto` so retention purging the visit cannot delete it. Returns the shaped record plus +`pin` and `photoCarriedOver`; the console opens the printable card. + +Refusals: no mobile number on the visit (the number is the sign in name); phone or email already +belongs to a recurring visitor (error names them and returns `existingId`). Site-scoped admins are +checked with `assertSiteAllowed`. The visit row is deliberately not modified or relinked. + +**Files:** `src/routes/admin.js`, `public/js/admin.js`, `README.md`. diff --git a/README.md b/README.md index c577e27..c9aca46 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,20 @@ retention cleaning up old visits can never delete a live profile photo. Leave the photo blank and they are asked at the kiosk as before. +### Turning a guest into a regular + +Anyone currently on site who gave a mobile number gets a **Make regular** button in the **On +site** list. It takes their name, company, mobile, email and check details straight from the +visit, issues a random PIN, and opens the printable card. + +Their sign in photo is kept on file as their profile photo, so from the next visit the kiosk +skips the camera: mobile number, PIN, pick a host, badge prints. + +The visit itself is left alone — it happened as a guest visit and the log goes on saying so. +The button does not appear for someone who only left an email address, because the mobile number +is their sign in name, and promoting the same person twice is refused with the name of the +existing record. + ### Removing someone **Remove** on the recurring visitors list deletes the saved record for good — the PIN stops diff --git a/public/js/admin.js b/public/js/admin.js index 629c28c..d815d93 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -185,6 +185,11 @@ async function loadOnsite() {
Their name, mobile number and check details are taken from this visit, a + PIN is issued, and their sign in photo is kept on file. Next time they enter their mobile + number and PIN, pick who they are seeing, and the badge prints — no form, no camera.
+This visit stays in the log as a guest visit.
`, + async () => { + btn.disabled = true; + try { + const saved = await api(`/visits/${btn.dataset.makeRegular}/make-regular`, { method: 'POST' }); + loadOnsite(); + showPin(saved.pin, saved.id); + } catch (err) { + toast(err.message, true); + } finally { + btn.disabled = false; + } + }, + { saveLabel: 'Save and issue a PIN' } + ); + }) + ); $$('[data-print]').forEach((btn) => btn.addEventListener('click', async () => { btn.disabled = true; diff --git a/src/routes/admin.js b/src/routes/admin.js index 9b701d5..ee3147f 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -5,7 +5,13 @@ import fs from 'node:fs'; import db from '../db.js'; import config from '../config.js'; import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js'; -import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js'; +import { + photoAbsolutePath, + deletePhoto, + purgeOldPhotos, + savePhoto, + copyStoredPhoto, +} from '../photos.js'; import * as sheets from '../sheets.js'; import * as tls from '../tls.js'; import * as printer from '../printer.js'; @@ -1116,6 +1122,91 @@ router.get('/visits', (req, res) => { res.json(db.prepare(sql).all(...params).map(shapeVisit)); }); +/** + * Promotes someone who is signing in as a guest into a saved recurring visitor, + * using the details they already gave at the kiosk. A random PIN is issued, and + * their photo is carried across so the kiosk stops asking them to pose. + * + * The visit itself is left alone: it happened as a guest visit and the log should + * go on saying so. + */ +router.post('/visits/:id/make-regular', (req, res) => { + const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id); + if (!visit) return res.status(404).json({ error: 'Not found.' }); + + try { + if (scopedSiteId(req)) assertSiteAllowed(req, visit.site_id); + } catch (err) { + return res.status(403).json({ error: err.message }); + } + + if (!visit.phone) { + return res.status(400).json({ + error: 'A mobile number is needed, because it becomes their sign in name. This visitor only left an email address.', + }); + } + + const phone = normalisePhone(visit.phone); + const email = normaliseEmail(visit.email); + + const byPhone = db + .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?') + .get(phone); + if (byPhone) { + return res.status(409).json({ + error: `${byPhone.first_name} ${byPhone.last_name} is already saved with that mobile number.`, + existingId: byPhone.id, + }); + } + if (email) { + const byEmail = db + .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?') + .get(email); + if (byEmail) { + return res.status(409).json({ + error: `${byEmail.first_name} ${byEmail.last_name} is already saved with that email address.`, + existingId: byEmail.id, + }); + } + } + + try { + const pin = allocatePin(null); + // Their sign-in photo becomes the one kept on file. Copied rather than shared, + // so retention purging the visit cannot remove their profile photo. + const photoPath = visit.photo_path ? copyStoredPhoto(visit.photo_path) : null; + + const info = db + .prepare( + `INSERT INTO frequent_visitors + (first_name, last_name, company, phone, email, check_type, check_number, check_expiry, + default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` + ) + .run( + visit.first_name, + visit.last_name, + visit.company || null, + phone, + email || null, + visit.check_type || 'NONE', + visit.check_number || null, + visit.check_expiry || null, + visit.host_id || null, + visit.site_id || null, + encryptPin(pin), + pinLookup(pin), + photoPath, + null + ); + + const created = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid); + res.json({ ...shapeFrequent(created, true), photoCarriedOver: Boolean(photoPath) }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + router.post('/visits/:id/signout', (req, res) => { const visit = db .prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')