Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+83
-18
@@ -4,8 +4,8 @@ import QRCode from 'qrcode';
|
||||
import fs from 'node:fs';
|
||||
import db from '../db.js';
|
||||
import config from '../config.js';
|
||||
import { decryptPin, encryptPin, generatePin } from '../pins.js';
|
||||
import { photoAbsolutePath, deletePhoto, purgeOldPhotos } from '../photos.js';
|
||||
import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
|
||||
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
|
||||
import * as sheets from '../sheets.js';
|
||||
import * as tls from '../tls.js';
|
||||
import * as users from '../users.js';
|
||||
@@ -378,7 +378,7 @@ router.patch('/sites/:id', (req, res) => {
|
||||
const badge = req.body?.badge || {};
|
||||
db.prepare(
|
||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||
badge_height_mm = ?, badge_show_photo = ?, badge_note = ? WHERE id = ?`
|
||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ? WHERE id = ?`
|
||||
).run(
|
||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||
@@ -387,6 +387,7 @@ router.patch('/sites/:id', (req, res) => {
|
||||
Math.min(200, Math.max(20, Number(badge.widthMm ?? site.badge_width_mm) || 86)),
|
||||
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
|
||||
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
|
||||
badge.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent,
|
||||
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note
|
||||
, site.id);
|
||||
|
||||
@@ -549,6 +550,7 @@ function shapeFrequent(row, includePin = false) {
|
||||
checkExpiry: row.check_expiry,
|
||||
defaultHostId: row.default_host_id,
|
||||
siteId: row.site_id,
|
||||
hasPhoto: Boolean(row.photo_path),
|
||||
notes: row.notes,
|
||||
active: Boolean(row.active),
|
||||
createdAt: row.created_at,
|
||||
@@ -607,7 +609,25 @@ router.get('/alerts', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
function validateFrequent(body, { existingPhone = null } = {}) {
|
||||
/** True when another recurring visitor already holds this PIN. */
|
||||
function pinTaken(pin, excludeId = null) {
|
||||
const row = db
|
||||
.prepare('SELECT id FROM frequent_visitors WHERE pin_lookup = ?')
|
||||
.get(pinLookup(pin));
|
||||
return Boolean(row) && row.id !== excludeId;
|
||||
}
|
||||
|
||||
function allocatePin(requested, excludeId = null) {
|
||||
if (requested && /^\d{4}$/.test(String(requested))) {
|
||||
if (pinTaken(String(requested), excludeId)) {
|
||||
throw new Error('Another recurring visitor already uses that PIN. Choose a different one.');
|
||||
}
|
||||
return String(requested);
|
||||
}
|
||||
return generatePin((candidate) => pinTaken(candidate, excludeId));
|
||||
}
|
||||
|
||||
function validateFrequent(body, { existingPhone = null, existingId = null } = {}) {
|
||||
const firstName = titleCase(body?.firstName, 60);
|
||||
const lastName = titleCase(body?.lastName, 60);
|
||||
const phone = normalisePhone(body?.phone);
|
||||
@@ -621,9 +641,24 @@ function validateFrequent(body, { existingPhone = null } = {}) {
|
||||
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
|
||||
throw new Error(`A ${checkType} number is required.`);
|
||||
}
|
||||
if (phone !== existingPhone) {
|
||||
const clash = db.prepare('SELECT id FROM frequent_visitors WHERE phone = ?').get(phone);
|
||||
if (clash) throw new Error('Another recurring visitor already uses that mobile number.');
|
||||
// One record per person: mobile number, email and PIN must each be unique.
|
||||
const phoneClash = db
|
||||
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?')
|
||||
.get(phone);
|
||||
if (phoneClash && phoneClash.id !== existingId) {
|
||||
throw new Error(
|
||||
`${phoneClash.first_name} ${phoneClash.last_name} already uses that mobile number.`
|
||||
);
|
||||
}
|
||||
if (email) {
|
||||
const emailClash = db
|
||||
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?')
|
||||
.get(email);
|
||||
if (emailClash && emailClash.id !== existingId) {
|
||||
throw new Error(
|
||||
`${emailClash.first_name} ${emailClash.last_name} already uses that email address.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstName,
|
||||
@@ -644,17 +679,19 @@ router.post('/frequent', (req, res) => {
|
||||
const v = validateFrequent(req.body);
|
||||
const scope = scopedSiteId(req);
|
||||
const siteId = scope || v.siteId;
|
||||
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
|
||||
const pin = allocatePin(req.body?.pin);
|
||||
const photoPath = req.body?.photo ? savePhoto(req.body.photo) : null;
|
||||
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO frequent_visitors
|
||||
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
|
||||
default_host_id, site_id, pin_enc, notes, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
)
|
||||
.run(
|
||||
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
||||
v.defaultHostId, siteId, encryptPin(pin), v.notes
|
||||
v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes
|
||||
);
|
||||
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
|
||||
} catch (err) {
|
||||
@@ -666,16 +703,31 @@ router.patch('/frequent/:id', (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Not found.' });
|
||||
try {
|
||||
const v = validateFrequent({ ...shapeFrequent(row), ...req.body }, { existingPhone: row.phone });
|
||||
const v = validateFrequent(
|
||||
{ ...shapeFrequent(row), ...req.body },
|
||||
{ existingPhone: row.phone, existingId: row.id }
|
||||
);
|
||||
const scope = scopedSiteId(req);
|
||||
|
||||
// A new photo replaces the old file; removePhoto clears it entirely.
|
||||
let photoPath = row.photo_path;
|
||||
if (req.body?.photo) {
|
||||
photoPath = savePhoto(req.body.photo);
|
||||
if (row.photo_path) deletePhoto(row.photo_path);
|
||||
} else if (req.body?.removePhoto) {
|
||||
if (row.photo_path) deletePhoto(row.photo_path);
|
||||
photoPath = null;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
|
||||
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
|
||||
notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).run(
|
||||
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
||||
v.defaultHostId,
|
||||
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
|
||||
photoPath,
|
||||
v.notes,
|
||||
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active,
|
||||
row.id
|
||||
@@ -686,14 +738,27 @@ router.patch('/frequent/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** The photo kept on file for a recurring visitor. */
|
||||
router.get('/frequent/:id/photo', (req, res) => {
|
||||
const row = db.prepare('SELECT photo_path FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||
const abs = row && photoAbsolutePath(row.photo_path);
|
||||
if (!abs) return res.status(404).send('No photo on file.');
|
||||
res.setHeader('Cache-Control', 'private, max-age=60');
|
||||
res.sendFile(abs);
|
||||
});
|
||||
|
||||
router.post('/frequent/:id/pin', (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Not found.' });
|
||||
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
|
||||
db.prepare("UPDATE frequent_visitors SET pin_enc = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
encryptPin(pin),
|
||||
row.id
|
||||
);
|
||||
let pin;
|
||||
try {
|
||||
pin = allocatePin(req.body?.pin, row.id);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
db.prepare(
|
||||
"UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(encryptPin(pin), pinLookup(pin), row.id);
|
||||
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
|
||||
res.json({ ok: true, pin });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user