Public Access
Recurring Visit Button
This commit is contained in:
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
|
||||
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`.
|
||||
@@ -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
|
||||
|
||||
@@ -185,6 +185,11 @@ async function loadOnsite() {
|
||||
<td class="actions">
|
||||
<button class="ghost" data-badge="${v.id}">Badge</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>
|
||||
</td>
|
||||
</tr>`
|
||||
@@ -202,6 +207,32 @@ async function loadOnsite() {
|
||||
$$('[data-badge]').forEach((btn) =>
|
||||
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) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
|
||||
+92
-1
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user