Recurring Visit Button

This commit is contained in:
jessikitty committed 2026-09-30 23:01:32 +10:00
1 parent 215c38849b
commit 1b1d950d71
4 files changed
+156 -1

No files matched your search

@@ -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 **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 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). 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`.
+14
View File
@@ -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. 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 ### Removing someone
**Remove** on the recurring visitors list deletes the saved record for good — the PIN stops **Remove** on the recurring visitors list deletes the saved record for good — the PIN stops
+31
View File
@@ -185,6 +185,11 @@ async function loadOnsite() {
<td class="actions"> <td class="actions">
<button class="ghost" data-badge="${v.id}">Badge</button> <button class="ghost" data-badge="${v.id}">Badge</button>
<button class="ghost" data-print="${v.id}">Print</button> <button class="ghost" data-print="${v.id}">Print</button>
${
v.visitorType !== 'frequent' && v.phone
? `<button class="ghost" data-make-regular="${v.id}" title="Save them with a PIN so they can sign in themselves next time">Make regular</button>`
: ''
}
<button class="ghost" data-signout="${v.id}">Sign out</button> <button class="ghost" data-signout="${v.id}">Sign out</button>
</td> </td>
</tr>` </tr>`
@@ -202,6 +207,32 @@ async function loadOnsite() {
$$('[data-badge]').forEach((btn) => $$('[data-badge]').forEach((btn) =>
btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank')) btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank'))
); );
$$('[data-make-regular]').forEach((btn) =>
btn.addEventListener('click', async () => {
const row = btn.closest('tr');
const name = row?.querySelector('strong')?.textContent?.trim() || 'this visitor';
openModal(
`Save ${esc(name)} as a regular visitor?`,
`<p class="hint">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.</p>
<p class="hint">This visit stays in the log as a guest visit.</p>`,
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) => $$('[data-print]').forEach((btn) =>
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
btn.disabled = true; btn.disabled = true;
+92 -1
View File
@@ -5,7 +5,13 @@ import fs from 'node:fs';
import db from '../db.js'; import db from '../db.js';
import config from '../config.js'; import config from '../config.js';
import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.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 sheets from '../sheets.js';
import * as tls from '../tls.js'; import * as tls from '../tls.js';
import * as printer from '../printer.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)); 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) => { router.post('/visits/:id/signout', (req, res) => {
const visit = db const visit = db
.prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL') .prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')