Public Access
Compare commits
2
Commits
8bc71792e3
...
b1a793302d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a793302d | ||
|
|
eb986581e4 |
@@ -212,6 +212,30 @@ it is laid out along the length and turned before printing, which is what you wa
|
|||||||
hangs from its short edge. Set it per site and check the bitmap preview — the two look very
|
hangs from its short edge. Set it per site and check the bitmap preview — the two look very
|
||||||
different and only one will suit how you hang them.
|
different and only one will suit how you hang them.
|
||||||
|
|
||||||
|
**Label stock** at the top of the badge settings is a shortcut that fills in the size, the roll
|
||||||
|
type and the colour option. It is not itself a saved setting — the three fields it fills are what
|
||||||
|
get stored, which is why it can appear to "revert" when the same dimensions describe two rolls.
|
||||||
|
|
||||||
|
**Diagnostics from the command line.** When the console is not enough:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec visitor-signin node scripts/print-test.mjs # render and print
|
||||||
|
docker compose exec visitor-signin node scripts/print-test.mjs --dry # render only
|
||||||
|
docker compose exec visitor-signin node scripts/print-test.mjs --label 62x100
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints the settings it is using, the exact `brother_ql` command, and the printer's full reply.
|
||||||
|
`--label` and `--rotate` override the saved settings for one run, so alternatives can be tried
|
||||||
|
without saving anything.
|
||||||
|
|
||||||
|
**Continuous versus die-cut matters.** `62` means a continuous roll cut to length; `62x100` means
|
||||||
|
pre-cut labels. Sending one id to the other kind of roll is refused as a wrong roll, and this is
|
||||||
|
the most common cause of that error after the two-colour setting.
|
||||||
|
|
||||||
|
**Diagnostics.** The site card has a **Diagnostics** button showing exactly what would be sent,
|
||||||
|
including the `brother_ql` command line, so it can be run by hand on the host. A failed test print
|
||||||
|
shows the printer's own words rather than a summary.
|
||||||
|
|
||||||
**If the printer cannot be reached**, sign in still completes. The kiosk falls back to its own
|
**If the printer cannot be reached**, sign in still completes. The kiosk falls back to its own
|
||||||
browser print dialog, and the failure is shown against the site in **Admin → Sites** with the
|
browser print dialog, and the failure is shown against the site in **Admin → Sites** with the
|
||||||
reason. Admins can reprint any badge from the **On site** list.
|
reason. Admins can reprint any badge from the **On site** list.
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"version": "node scripts/version.mjs",
|
"version": "node scripts/version.mjs",
|
||||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
|
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
|
||||||
|
"print-test": "node scripts/print-test.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -400,3 +400,18 @@ tr.row-bad td { background: #fdf0f2; }
|
|||||||
vertical-align: -2px;
|
vertical-align: -2px;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre.raw {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|||||||
+55
-12
@@ -689,6 +689,7 @@ async function loadSites() {
|
|||||||
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
|
||||||
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
|
<button class="ghost" data-bitmap="${s.id}">Bitmap preview</button>
|
||||||
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
|
${s.printer.enabled ? `<button class="ghost" data-test-print="${s.id}">Test print</button>` : ''}
|
||||||
|
${s.printer.enabled ? `<button class="ghost" data-print-diag="${s.id}">Diagnostics</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl class="site-meta">
|
<dl class="site-meta">
|
||||||
@@ -740,6 +741,17 @@ async function loadSites() {
|
|||||||
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
|
window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
$$('[data-print-diag]').forEach((btn) =>
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const d = await api(`/sites/${btn.dataset.printDiag}/printer-diagnostics`);
|
||||||
|
openModal(
|
||||||
|
'Printer diagnostics',
|
||||||
|
`<pre class="raw">${esc(JSON.stringify(d, null, 2))}</pre>`,
|
||||||
|
null,
|
||||||
|
{ saveLabel: 'Close', hideCancel: true }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
$$('[data-test-print]').forEach((btn) =>
|
$$('[data-test-print]').forEach((btn) =>
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -748,7 +760,17 @@ async function loadSites() {
|
|||||||
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
|
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
|
||||||
toast('Sent to the printer.');
|
toast('Sent to the printer.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
// Show the printer's own words as well as the summary, because a refusal
|
||||||
|
// usually names the reason and the summary cannot cover every case.
|
||||||
|
const raw = err.payload?.raw;
|
||||||
|
openModal(
|
||||||
|
'The printer refused the job',
|
||||||
|
`<p>${esc(err.message)}</p>
|
||||||
|
${err.payload?.command ? `<p class="hint">Command:</p><pre class="raw">${esc(err.payload.command)}</pre>` : ''}
|
||||||
|
${raw ? `<p class="hint">What the printer and brother_ql said:</p><pre class="raw">${esc(raw)}</pre>` : ''}`,
|
||||||
|
null,
|
||||||
|
{ saveLabel: 'Close', hideCancel: true }
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Test print';
|
btn.textContent = 'Test print';
|
||||||
@@ -764,16 +786,29 @@ async function loadSites() {
|
|||||||
* 62 mm is for a different printer.
|
* 62 mm is for a different printer.
|
||||||
*/
|
*/
|
||||||
const LABEL_PRESETS = [
|
const LABEL_PRESETS = [
|
||||||
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
|
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62' },
|
||||||
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
|
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true, roll: '62' },
|
||||||
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
|
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62red', accent: true },
|
||||||
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
|
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false, roll: '62' },
|
||||||
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
|
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false, roll: '62' },
|
||||||
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
|
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false, roll: '62' },
|
||||||
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
|
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true, roll: '62' },
|
||||||
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
|
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true, roll: '62' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which preset the saved settings correspond to. Dimensions alone are ambiguous —
|
||||||
|
* DK-22205 and DK-22251 are both 62 x 90 — so the roll type decides between them.
|
||||||
|
*/
|
||||||
|
function matchingPreset(site) {
|
||||||
|
return LABEL_PRESETS.find(
|
||||||
|
(p) =>
|
||||||
|
Number(site.badge.widthMm) === p.w &&
|
||||||
|
Number(site.badge.heightMm) === p.h &&
|
||||||
|
p.roll === (site.printer.label === '62red' ? '62red' : '62')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function openSiteModal(site) {
|
function openSiteModal(site) {
|
||||||
openModal(
|
openModal(
|
||||||
`Edit ${site.name}`,
|
`Edit ${site.name}`,
|
||||||
@@ -787,11 +822,11 @@ function openSiteModal(site) {
|
|||||||
<option value="">Custom size</option>
|
<option value="">Custom size</option>
|
||||||
${LABEL_PRESETS.map(
|
${LABEL_PRESETS.map(
|
||||||
(p) =>
|
(p) =>
|
||||||
`<option value="${p.id}" ${
|
`<option value="${p.id}" ${matchingPreset(site)?.id === p.id ? 'selected' : ''}>${esc(p.label)}</option>`
|
||||||
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
|
|
||||||
}>${esc(p.label)}</option>`
|
|
||||||
).join('')}
|
).join('')}
|
||||||
</select></label>
|
</select></label>
|
||||||
|
<p class="hint">A shortcut that fills in the boxes below. The size, the roll type and the
|
||||||
|
colour option are what actually get saved.</p>
|
||||||
<div class="modal-row">
|
<div class="modal-row">
|
||||||
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
||||||
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
||||||
@@ -1083,6 +1118,14 @@ function wireBadgePreset() {
|
|||||||
height.value = preset.h;
|
height.value = preset.h;
|
||||||
$('#badge-photo').checked = preset.photo;
|
$('#badge-photo').checked = preset.photo;
|
||||||
$('#badge-accent').checked = Boolean(preset.accent);
|
$('#badge-accent').checked = Boolean(preset.accent);
|
||||||
|
// The preset knows which roll it describes, so keep the two in step. Picking
|
||||||
|
// the DK-22251 preset while the roll stayed on "black only" is what makes the
|
||||||
|
// printer refuse the job.
|
||||||
|
const roll = $('#modal-form [name="printerLabel"]');
|
||||||
|
if (roll && preset.roll) {
|
||||||
|
roll.value = preset.roll;
|
||||||
|
roll.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
check();
|
check();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Renders a badge and prints it, showing everything on the way through.
|
||||||
|
*
|
||||||
|
* For working out why a printer will not accept a job, without going through the
|
||||||
|
* kiosk or the admin console. Run inside the container:
|
||||||
|
*
|
||||||
|
* docker compose exec visitor-signin node scripts/print-test.mjs
|
||||||
|
* docker compose exec visitor-signin node scripts/print-test.mjs --label 62x100
|
||||||
|
* docker compose exec visitor-signin node scripts/print-test.mjs --dry
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --site N which site to use (default: the first one)
|
||||||
|
* --label ID override the roll id sent to the printer, without saving it
|
||||||
|
* --rotate DEG override the rotation, without saving it
|
||||||
|
* --dry render only, do not print
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import db from '../src/db.js';
|
||||||
|
import config from '../src/config.js';
|
||||||
|
import * as printer from '../src/printer.js';
|
||||||
|
|
||||||
|
function arg(name, fallback = null) {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
|
||||||
|
? process.argv[i + 1]
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
const has = (name) => process.argv.includes(`--${name}`);
|
||||||
|
|
||||||
|
const siteId = Number(arg('site', 0));
|
||||||
|
const site = siteId
|
||||||
|
? db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId)
|
||||||
|
: db.prepare('SELECT * FROM sites ORDER BY id LIMIT 1').get();
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
console.error('No sites exist yet.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg('label')) site.printer_label = arg('label');
|
||||||
|
if (arg('rotate')) site.printer_rotate = Number(arg('rotate'));
|
||||||
|
|
||||||
|
const label = printer.labelFor(site);
|
||||||
|
const target = `tcp://${site.printer_host}:${site.printer_port || 9100}`;
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(` site ${site.name}`);
|
||||||
|
console.log(` printer ${site.printer_model || 'QL-820NWB'} at ${target}`);
|
||||||
|
console.log(` server printing ${site.printer_enabled ? 'on' : 'OFF — the kiosk would print instead'}`);
|
||||||
|
console.log(` badge ${site.badge_width_mm} x ${site.badge_height_mm} mm, rotate ${site.printer_rotate || 0}`);
|
||||||
|
console.log(` roll setting ${site.printer_label || '(unset)'} -> --label ${label}`);
|
||||||
|
console.log(` red requested ${Boolean(site.badge_accent)}, will print ${printer.accentWillPrintRed(site)}`);
|
||||||
|
|
||||||
|
const png = await printer.renderBadgePng(printer.sampleVisit(site), site);
|
||||||
|
const file = '/tmp/print-test.png';
|
||||||
|
fs.writeFileSync(file, png);
|
||||||
|
console.log(` rendered ${png.readUInt32BE(16)} x ${png.readUInt32BE(20)} dots -> ${file}`);
|
||||||
|
|
||||||
|
if (!site.printer_host) {
|
||||||
|
console.error('\n No printer address set for this site. Set one in Admin -> Sites -> Edit.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has('dry')) {
|
||||||
|
console.log('\n --dry given, so nothing was sent.\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--backend', 'network',
|
||||||
|
'--model', site.printer_model || 'QL-820NWB',
|
||||||
|
'--printer', target,
|
||||||
|
'print',
|
||||||
|
'--label', label,
|
||||||
|
file,
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(` running: ${config.printing.command} ${args.join(' ')}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = execFileSync(config.printing.command, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: config.printing.timeoutMs,
|
||||||
|
});
|
||||||
|
console.log(out.trim() || ' (no output)');
|
||||||
|
console.log('\n Sent. If nothing came out, the printer rejected it silently — check its display.\n');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(' FAILED\n');
|
||||||
|
console.error(`${err.stdout || ''}${err.stderr || ''}`.trim() || err.message);
|
||||||
|
console.error('');
|
||||||
|
console.error(' Roll ids this printer understands:');
|
||||||
|
console.error(' 62 62 mm continuous, black only');
|
||||||
|
console.error(' 62red 62 mm continuous, black and red (DK-22251)');
|
||||||
|
console.error(' 62x100 62 x 100 mm DIE-CUT (pre-cut labels, not a continuous roll)');
|
||||||
|
console.error(' 62x29 62 x 29 mm die-cut');
|
||||||
|
console.error('');
|
||||||
|
console.error(' A continuous roll sent a die-cut id, or the reverse, is refused as a wrong roll.');
|
||||||
|
console.error(' Try: node scripts/print-test.mjs --label 62x100');
|
||||||
|
console.error('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
+14
-12
@@ -336,24 +336,26 @@ export async function printBadge(visit, site) {
|
|||||||
const port = Number(site.printer_port) || 9100;
|
const port = Number(site.printer_port) || 9100;
|
||||||
const target = `tcp://${site.printer_host}:${port}`;
|
const target = `tcp://${site.printer_host}:${port}`;
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--backend', 'network',
|
||||||
|
'--model', site.printer_model || 'QL-820NWB',
|
||||||
|
'--printer', target,
|
||||||
|
'print',
|
||||||
|
'--label', labelFor(site),
|
||||||
|
file,
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runBrotherQl(
|
await runBrotherQl(args, config.printing.timeoutMs);
|
||||||
[
|
|
||||||
'--backend', 'network',
|
|
||||||
'--model', site.printer_model || 'QL-820NWB',
|
|
||||||
'--printer', target,
|
|
||||||
'print',
|
|
||||||
'--label', labelFor(site),
|
|
||||||
file,
|
|
||||||
],
|
|
||||||
config.printing.timeoutMs
|
|
||||||
);
|
|
||||||
note(site.id, true, `Printed to ${site.printer_host}`);
|
note(site.id, true, `Printed to ${site.printer_host}`);
|
||||||
return { ok: true, target };
|
return { ok: true, target };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const friendly = explainPrintError(err.message, site.printer_host);
|
const friendly = explainPrintError(err.message, site.printer_host);
|
||||||
note(site.id, false, friendly);
|
note(site.id, false, friendly);
|
||||||
throw new Error(friendly);
|
const wrapped = new Error(friendly);
|
||||||
|
wrapped.raw = err.message;
|
||||||
|
wrapped.command = `${config.printing.command} ${args.join(' ')}`;
|
||||||
|
throw wrapped;
|
||||||
} finally {
|
} finally {
|
||||||
fs.rm(file, { force: true }, () => {});
|
fs.rm(file, { force: true }, () => {});
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -510,10 +510,40 @@ router.post('/sites/:id/test-print', async (req, res) => {
|
|||||||
const result = await printer.printBadge(printer.sampleVisit(site), site);
|
const result = await printer.printBadge(printer.sampleVisit(site), site);
|
||||||
res.json({ ok: true, ...result });
|
res.json({ ok: true, ...result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
// The raw output and the exact command matter when the friendly message is
|
||||||
|
// not enough — a printer refusing a job says why in its own words.
|
||||||
|
res.status(400).json({ error: err.message, raw: err.raw || null, command: err.command || null });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Everything about how this site would print, for working out why it will not. */
|
||||||
|
router.get('/sites/:id/printer-diagnostics', (req, res) => {
|
||||||
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
|
if (!site) return res.status(404).json({ error: 'Not found.' });
|
||||||
|
res.json({
|
||||||
|
site: site.name,
|
||||||
|
printing: {
|
||||||
|
enabled: Boolean(site.printer_enabled),
|
||||||
|
host: site.printer_host,
|
||||||
|
port: site.printer_port,
|
||||||
|
model: site.printer_model,
|
||||||
|
rotate: site.printer_rotate,
|
||||||
|
},
|
||||||
|
rollSetting: site.printer_label,
|
||||||
|
labelSentToPrinter: printer.labelFor(site),
|
||||||
|
redRequested: Boolean(site.badge_accent),
|
||||||
|
redWillPrint: printer.accentWillPrintRed(site),
|
||||||
|
badgeMm: { width: site.badge_width_mm, height: site.badge_height_mm },
|
||||||
|
lastResult: printer.printerStatus(site.id),
|
||||||
|
command: [
|
||||||
|
'brother_ql --backend network',
|
||||||
|
`--model ${site.printer_model || 'QL-820NWB'}`,
|
||||||
|
`--printer tcp://${site.printer_host}:${site.printer_port || 9100}`,
|
||||||
|
`print --label ${printer.labelFor(site)} badge.png`,
|
||||||
|
].join(' '),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/** Reprints a real visitor's badge on the server's printer. */
|
/** Reprints a real visitor's badge on the server's printer. */
|
||||||
router.post('/visits/:id/print', async (req, res) => {
|
router.post('/visits/:id/print', async (req, res) => {
|
||||||
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user