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
|
**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`.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
Reference in new issue
Block a user