Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -318,6 +318,21 @@ 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.
|
||||||
|
|
||||||
|
### Removing someone
|
||||||
|
|
||||||
|
**Remove** on the recurring visitors list deletes the saved record for good — the PIN stops
|
||||||
|
working, the stored photo is deleted, and their mobile number, email and PIN become available
|
||||||
|
for someone else.
|
||||||
|
|
||||||
|
Their **visit history is kept**. Visits store the name, contact details and host as their own
|
||||||
|
columns, so the log remains a complete record of who was in the building regardless of whether
|
||||||
|
the person is still on file. Removing someone who is currently signed in does not sign them out;
|
||||||
|
the confirmation says so, and their visit can still be closed with their last name and mobile
|
||||||
|
number at the kiosk.
|
||||||
|
|
||||||
|
If they might come back, untick **Active** in Edit instead. That keeps the record, the PIN and
|
||||||
|
the history, but stops the PIN working at the kiosk.
|
||||||
|
|
||||||
### PINs
|
### PINs
|
||||||
|
|
||||||
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
|
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
|
||||||
|
|||||||
@@ -344,3 +344,10 @@ tr.row-bad td { background: #fdf0f2; }
|
|||||||
background: #fdf3d8;
|
background: #fdf3d8;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plain-list { margin: 0 0 14px; padding-left: 20px; font-size: 14.5px; }
|
||||||
|
.plain-list li { margin-bottom: 5px; }
|
||||||
|
|
||||||
|
/* The confirmation for a destructive action should not look like a Save. */
|
||||||
|
#modal.destructive { border-top-color: var(--alert); }
|
||||||
|
#modal.destructive .primary { border-color: var(--alert); background: var(--alert); }
|
||||||
|
|||||||
@@ -215,7 +215,15 @@ a:focus-visible {
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.host-option small { display: block; color: var(--muted); font-size: 14px; }
|
.host-option small { display: block; color: var(--muted); font-size: 14px; }
|
||||||
.host-empty { color: var(--muted); font-size: 15px; padding: 10px 2px; }
|
.host-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
margin: -6px 0 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-left: 4px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------- camera */
|
/* ---------------------------------------------------------- camera */
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -63,14 +63,14 @@
|
|||||||
<div class="rail" data-rail></div>
|
<div class="rail" data-rail></div>
|
||||||
<h2>Who are you here to see?</h2>
|
<h2>Who are you here to see?</h2>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Pick from the list</span>
|
<span>Start typing a name to narrow the list</span>
|
||||||
<select id="host-select" class="picker"></select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Or start typing a name</span>
|
|
||||||
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
|
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
|
||||||
</label>
|
</label>
|
||||||
<div class="host-list" id="host-list" role="listbox" aria-label="People you can visit"></div>
|
<label class="field">
|
||||||
|
<span>Then choose a name</span>
|
||||||
|
<select id="host-select" class="picker"></select>
|
||||||
|
</label>
|
||||||
|
<p class="host-empty" id="host-empty" hidden></p>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<button class="ghost" data-back>Back</button>
|
<button class="ghost" data-back>Back</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,14 +166,14 @@
|
|||||||
<section class="screen" id="screen-freq-host">
|
<section class="screen" id="screen-freq-host">
|
||||||
<h2 id="freq-greeting">Who are you here to see?</h2>
|
<h2 id="freq-greeting">Who are you here to see?</h2>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Pick from the list</span>
|
<span>Start typing a name to narrow the list</span>
|
||||||
<select id="freq-host-select" class="picker"></select>
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span>Or start typing a name</span>
|
|
||||||
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
|
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
|
||||||
</label>
|
</label>
|
||||||
<div class="host-list" id="freq-host-list" role="listbox" aria-label="People you can visit"></div>
|
<label class="field">
|
||||||
|
<span>Then choose a name</span>
|
||||||
|
<select id="freq-host-select" class="picker"></select>
|
||||||
|
</label>
|
||||||
|
<p class="host-empty" id="freq-host-empty" hidden></p>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<button class="ghost" data-go="home">Cancel</button>
|
<button class="ghost" data-go="home">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+55
-1
@@ -268,6 +268,7 @@ async function loadRecurring() {
|
|||||||
<button class="ghost" data-pass="${p.id}">Print card</button>
|
<button class="ghost" data-pass="${p.id}">Print card</button>
|
||||||
<button class="ghost" data-pin="${p.id}">New PIN</button>
|
<button class="ghost" data-pin="${p.id}">New PIN</button>
|
||||||
<button class="ghost" data-edit-freq="${p.id}">Edit</button>
|
<button class="ghost" data-edit-freq="${p.id}">Edit</button>
|
||||||
|
<button class="ghost danger" data-remove-freq="${p.id}">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
),
|
),
|
||||||
@@ -289,6 +290,15 @@ async function loadRecurring() {
|
|||||||
openRecurringModal(await api(`/frequent/${btn.dataset.editFreq}`));
|
openRecurringModal(await api(`/frequent/${btn.dataset.editFreq}`));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
$$('[data-remove-freq]').forEach((btn) =>
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
confirmRemoveFrequent(await api(`/frequent/${btn.dataset.removeFreq}`));
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostOptions(selectedId) {
|
function hostOptions(selectedId) {
|
||||||
@@ -316,18 +326,25 @@ function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false, onOpen, onClose } = {}) {
|
function openModal(
|
||||||
|
title,
|
||||||
|
bodyHtml,
|
||||||
|
onSave,
|
||||||
|
{ saveLabel = 'Save', hideCancel = false, destructive = false, onOpen, onClose } = {}
|
||||||
|
) {
|
||||||
$('#modal-title').textContent = title;
|
$('#modal-title').textContent = title;
|
||||||
$('#modal-body').innerHTML = bodyHtml;
|
$('#modal-body').innerHTML = bodyHtml;
|
||||||
$('#modal-save').textContent = saveLabel;
|
$('#modal-save').textContent = saveLabel;
|
||||||
$('#modal-cancel').hidden = hideCancel;
|
$('#modal-cancel').hidden = hideCancel;
|
||||||
const modal = $('#modal');
|
const modal = $('#modal');
|
||||||
|
modal.classList.toggle('destructive', destructive);
|
||||||
modal.returnValue = '';
|
modal.returnValue = '';
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
onOpen?.();
|
onOpen?.();
|
||||||
modal.onclose = () => {
|
modal.onclose = () => {
|
||||||
$('#modal-cancel').hidden = false;
|
$('#modal-cancel').hidden = false;
|
||||||
$('#modal-save').textContent = 'Save';
|
$('#modal-save').textContent = 'Save';
|
||||||
|
modal.classList.remove('destructive');
|
||||||
onClose?.();
|
onClose?.();
|
||||||
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
||||||
};
|
};
|
||||||
@@ -494,6 +511,43 @@ function openRecurringModal(person = null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removing a saved visitor is permanent, so the confirmation spells out what
|
||||||
|
* happens: the record and its PIN go, the visit history stays. Deactivating is
|
||||||
|
* offered alongside for the common case of someone who has simply stopped coming.
|
||||||
|
*/
|
||||||
|
function confirmRemoveFrequent(person) {
|
||||||
|
const name = `${person.firstName} ${person.lastName}`;
|
||||||
|
openModal(
|
||||||
|
`Remove ${name}?`,
|
||||||
|
`${
|
||||||
|
person.onSite
|
||||||
|
? `<p class="notice">${esc(person.firstName)} is signed in right now. Removing the
|
||||||
|
record will not sign them out — their visit stays open and they can still sign out
|
||||||
|
with their last name and mobile number.</p>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
<ul class="plain-list">
|
||||||
|
<li>Their saved record, PIN and photo are deleted for good.</li>
|
||||||
|
<li>Their ${person.visitCount} past ${person.visitCount === 1 ? 'visit stays' : 'visits stay'} in the visit log.</li>
|
||||||
|
<li>${esc(person.phone)}${person.email ? ` and ${esc(person.email)}` : ''} become available for someone else.</li>
|
||||||
|
<li>Any card they are carrying stops working.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">If they might come back, untick <strong>Active</strong> in Edit instead —
|
||||||
|
that keeps the record and their history intact.</p>`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const result = await api(`/frequent/${person.id}?force=1`, { method: 'DELETE' });
|
||||||
|
toast(`${result.name} removed. ${result.visitsKept} past visit(s) kept in the log.`);
|
||||||
|
loadRecurring();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ saveLabel: 'Remove permanently', destructive: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function showPin(pin, id) {
|
function showPin(pin, id) {
|
||||||
openModal(
|
openModal(
|
||||||
'PIN issued',
|
'PIN issued',
|
||||||
|
|||||||
+49
-38
@@ -186,12 +186,12 @@ tickClock();
|
|||||||
/* --------------------------------------------------------------- hosts */
|
/* --------------------------------------------------------------- hosts */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two ways to say who you are visiting, because neither suits everyone: a dropdown
|
* Who you are here to see: type to narrow, then choose from the dropdown. The
|
||||||
* for someone who wants to browse a short list, and a filter box for someone who
|
* dropdown is a native select, so a tablet gives it a proper full-screen picker
|
||||||
* knows the name and would rather type it. The filter narrows both, so the two
|
* with its own scrolling, which handles a long staff list better than a page of
|
||||||
* never disagree about what is on offer.
|
* buttons ever did.
|
||||||
*/
|
*/
|
||||||
function renderHosts(listEl, searchValue, onPick, selectEl = null) {
|
function renderHosts(selectEl, searchValue, onPick, emptyEl = null) {
|
||||||
const term = String(searchValue || '').trim().toLowerCase();
|
const term = String(searchValue || '').trim().toLowerCase();
|
||||||
const matches = term
|
const matches = term
|
||||||
? hosts.filter(
|
? hosts.filter(
|
||||||
@@ -199,19 +199,14 @@ function renderHosts(listEl, searchValue, onPick, selectEl = null) {
|
|||||||
)
|
)
|
||||||
: hosts;
|
: hosts;
|
||||||
|
|
||||||
const choose = (id, name) => {
|
const label = !hosts.length
|
||||||
state.hostId = Number(id);
|
? 'Nobody has been added yet'
|
||||||
state.hostName = name;
|
: matches.length === hosts.length
|
||||||
onPick();
|
? `Choose one of ${hosts.length}`
|
||||||
};
|
: `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`;
|
||||||
|
|
||||||
if (selectEl) {
|
|
||||||
const placeholder =
|
|
||||||
matches.length === hosts.length
|
|
||||||
? `${hosts.length} ${hosts.length === 1 ? 'person' : 'people'} — choose a name`
|
|
||||||
: `${matches.length} ${matches.length === 1 ? 'match' : 'matches'}`;
|
|
||||||
selectEl.innerHTML =
|
selectEl.innerHTML =
|
||||||
`<option value="">${escapeHtml(placeholder)}</option>` +
|
`<option value="">${escapeHtml(label)}</option>` +
|
||||||
matches
|
matches
|
||||||
.map(
|
.map(
|
||||||
(h) =>
|
(h) =>
|
||||||
@@ -219,29 +214,33 @@ function renderHosts(listEl, searchValue, onPick, selectEl = null) {
|
|||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
selectEl.disabled = matches.length === 0;
|
selectEl.disabled = matches.length === 0;
|
||||||
|
|
||||||
selectEl.onchange = () => {
|
selectEl.onchange = () => {
|
||||||
const picked = hosts.find((h) => h.id === Number(selectEl.value));
|
const picked = hosts.find((h) => h.id === Number(selectEl.value));
|
||||||
if (picked) choose(picked.id, picked.name);
|
if (!picked) return;
|
||||||
|
state.hostId = picked.id;
|
||||||
|
state.hostName = picked.name;
|
||||||
|
onPick();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = matches.length > 0;
|
||||||
|
emptyEl.textContent = hosts.length
|
||||||
|
? 'No one matches that. Check the spelling, or ask the front desk.'
|
||||||
|
: 'No one has been added for this site yet. Please see the front desk.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matches.length) {
|
// Typing a name and pressing enter should just work when only one person is left.
|
||||||
listEl.innerHTML = `<p class="host-empty">No one matches that. Check the spelling, or ask the front desk.</p>`;
|
selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : '';
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listEl.innerHTML = matches
|
/** Enter in the filter box picks the person when the filter leaves exactly one. */
|
||||||
.slice(0, 60)
|
function pickOnlyMatch(selectEl) {
|
||||||
.map(
|
const only = selectEl.dataset.only;
|
||||||
(h) =>
|
if (!only) return false;
|
||||||
`<button class="host-option" data-host-id="${h.id}" data-host-name="${escapeHtml(h.name)}">
|
selectEl.value = only;
|
||||||
${escapeHtml(h.name)}${h.area ? `<small>${escapeHtml(h.area)}</small>` : ''}
|
selectEl.dispatchEvent(new Event('change'));
|
||||||
</button>`
|
return true;
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
listEl.querySelectorAll('[data-host-id]').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => choose(btn.dataset.hostId, btn.dataset.hostName));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -447,7 +446,7 @@ async function loadSiteContext() {
|
|||||||
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
|
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
|
||||||
|
|
||||||
hosts = await api('/api/hosts');
|
hosts = await api('/api/hosts');
|
||||||
renderHosts($('#host-list'), '', () => show('guest-contact'), $('#host-select'));
|
renderHosts($('#host-select'), '', () => show('guest-contact'), $('#host-empty'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#change-site').addEventListener('click', chooseSite);
|
$('#change-site').addEventListener('click', chooseSite);
|
||||||
@@ -482,8 +481,14 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#in-host-search').addEventListener('input', (e) =>
|
$('#in-host-search').addEventListener('input', (e) =>
|
||||||
renderHosts($('#host-list'), e.target.value, () => show('guest-contact'), $('#host-select'))
|
renderHosts($('#host-select'), e.target.value, () => show('guest-contact'), $('#host-empty'))
|
||||||
);
|
);
|
||||||
|
$('#in-host-search').addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
pickOnlyMatch($('#host-select'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('[data-next="guest-contact"]').addEventListener('click', () => {
|
$('[data-next="guest-contact"]').addEventListener('click', () => {
|
||||||
const phone = $('#in-phone').value.trim();
|
const phone = $('#in-phone').value.trim();
|
||||||
@@ -557,10 +562,10 @@ $('#do-freq-auth').addEventListener('click', async () => {
|
|||||||
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
|
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
|
||||||
$('#in-freq-host-search').value = '';
|
$('#in-freq-host-search').value = '';
|
||||||
renderHosts(
|
renderHosts(
|
||||||
$('#freq-host-list'),
|
$('#freq-host-select'),
|
||||||
'',
|
'',
|
||||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||||
$('#freq-host-select')
|
$('#freq-host-empty')
|
||||||
);
|
);
|
||||||
show('freq-host');
|
show('freq-host');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -573,12 +578,18 @@ $('#do-freq-auth').addEventListener('click', async () => {
|
|||||||
|
|
||||||
$('#in-freq-host-search').addEventListener('input', (e) =>
|
$('#in-freq-host-search').addEventListener('input', (e) =>
|
||||||
renderHosts(
|
renderHosts(
|
||||||
$('#freq-host-list'),
|
$('#freq-host-select'),
|
||||||
e.target.value,
|
e.target.value,
|
||||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||||
$('#freq-host-select')
|
$('#freq-host-empty')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
$('#in-freq-host-search').addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
pickOnlyMatch($('#freq-host-select'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* sign out */
|
/* sign out */
|
||||||
|
|
||||||
|
|||||||
+54
-3
@@ -585,7 +585,18 @@ router.get('/frequent', (req, res) => {
|
|||||||
router.get('/frequent/:id', (req, res) => {
|
router.get('/frequent/:id', (req, res) => {
|
||||||
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||||
if (!row) return res.status(404).json({ error: 'Not found.' });
|
if (!row) return res.status(404).json({ error: 'Not found.' });
|
||||||
res.json(shapeFrequent(row, true));
|
res.json({
|
||||||
|
...shapeFrequent(row, true),
|
||||||
|
// Context for the removal confirmation.
|
||||||
|
visitCount: db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?')
|
||||||
|
.get(row.id).n,
|
||||||
|
onSite: Boolean(
|
||||||
|
db
|
||||||
|
.prepare('SELECT 1 FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL')
|
||||||
|
.get(row.id)
|
||||||
|
),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Everyone whose check lapses inside the warning window, or already has. */
|
/** Everyone whose check lapses inside the warning window, or already has. */
|
||||||
@@ -763,9 +774,49 @@ router.post('/frequent/:id/pin', (req, res) => {
|
|||||||
res.json({ ok: true, pin });
|
res.json({ ok: true, pin });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a recurring visitor for good.
|
||||||
|
*
|
||||||
|
* Their visit history is deliberately kept: visits store the name, contact details
|
||||||
|
* and host as their own columns, so the log stays a complete record of who was in
|
||||||
|
* the building even after the person's saved record is gone. Deleting the record
|
||||||
|
* frees their mobile number, email and PIN for someone else.
|
||||||
|
*
|
||||||
|
* To keep someone on file but stop them signing in, untick Active instead.
|
||||||
|
*/
|
||||||
router.delete('/frequent/:id', (req, res) => {
|
router.delete('/frequent/:id', (req, res) => {
|
||||||
db.prepare('UPDATE frequent_visitors SET active = 0 WHERE id = ?').run(req.params.id);
|
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||||
res.json({ ok: true });
|
if (!row) return res.status(404).json({ error: 'Not found.' });
|
||||||
|
|
||||||
|
const scope = scopedSiteId(req);
|
||||||
|
if (scope && row.site_id && row.site_id !== scope) {
|
||||||
|
return res.status(403).json({ error: 'That visitor belongs to another site.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const openVisit = db
|
||||||
|
.prepare('SELECT id FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL')
|
||||||
|
.get(row.id);
|
||||||
|
if (openVisit && !req.query.force) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `${row.first_name} is signed in right now. Sign them out first, or confirm to remove anyway.`,
|
||||||
|
onSite: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitCount = db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?')
|
||||||
|
.get(row.id).n;
|
||||||
|
|
||||||
|
if (row.photo_path) deletePhoto(row.photo_path);
|
||||||
|
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
|
||||||
|
// visits.frequent_visitor_id is ON DELETE SET NULL, so the history survives.
|
||||||
|
db.prepare('DELETE FROM frequent_visitors WHERE id = ?').run(row.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
name: `${row.first_name} ${row.last_name}`,
|
||||||
|
visitsKept: visitCount,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------- visits */
|
/* ------------------------------------------------------------- visits */
|
||||||
|
|||||||
Reference in New Issue
Block a user