feat(events): gallery QR code + printable table-card/poster PDFs (#847)

* feat(events): gallery QR code + printable table-card/poster PDFs (#836)

- GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or
  SVG, inline or attachment; adminAuth + events.view + ownership.
- GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card /
  A4 poster with event name, QR, localized caption (8 locales; Cyrillic
  falls back to English — built-in Helvetica has no Cyrillic glyphs) and
  the share URL as footer.
- Event detail: QR section in ShareLinkCard with live preview (blob
  fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print
  language follows the admin UI language. i18n keys in all 8 locales.
- qrcode + pdfkit were already dependencies (MFA / CRM PDFs).

* fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847)

- QR URLs: prefer the configured public base, but fall back to the admin
  browser's origin (passed as ?origin=, validated) when the base is
  missing or localhost — mirrors buildShareLinkUrl so the QR encodes the
  same URL the card displays instead of an unusable localhost target.
- PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek)
  instead of WinAnsi-only Helvetica: Cyrillic event names no longer
  silently disappear, and the caption's English-fallback hack is gone.
- Fixed vertical layout: title gets a bounded two-line ellipsis region
  and all positions derive from constants, so long event names can't
  push the QR/caption over the footer; URL footer bounded too.
- ShareLinkCard preview: stale-response guard — a late blob response
  after unmount/event-switch is revoked instead of leaking and
  overwriting the newer event's QR.

* fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2)

Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files
are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix
Cyrillic titles and regressed the ru caption. Now bundling the complete
IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap
verified via fontkit, rendering verified on a generated PDF) under
assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside.
~400 KB total; source: IBM/plex release zip @ibm/[email protected].

Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a
FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like
the frontend's own URL logic does.

Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only
subsets and share the Cyrillic gap.

* fix(events): responsive QR card that survives preview failures (codex review of #847, round 3)

- The QR section keys off share-link availability instead of a loaded
  preview: a transient failure of the preview request no longer hides
  every download button until reload; a placeholder tile renders in
  place of the image.
- Preview + actions stack on phone widths and the button grid drops to
  one column below sm, so 'Tischkarte (A6)'-length labels don't
  overflow.

* fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round)

- The QR target is now the STORED share_link — exactly what the card
  displays and the admin copies. Rebuilding from current slug/token/
  short-URL setting could diverge for legacy absolute links or events
  created under a different short-URL setting; a printed QR encoding a
  different URL than the card is a permanent mistake. Rebuild remains
  only as fallback when no share_link is stored.
- QR margin back to the library's 4-module default for all generated
  assets — the spec's quiet zone; margin 2 risks scan failures when the
  printout sits against colored surroundings.

* fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round)

Quote-/contract-converted events persist share_link as the raw token —
the frontend's buildShareLinkUrl prefixes those with /gallery/, but the
QR path normalization only added a leading slash, encoding
<origin>/<token> into every image/PDF for such events. Now mirrors the
frontend exactly.

* test(events): 30s timeout for the print-PDF cases (CI fix)

The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each);
font parsing + subsetting exceeds jest's 5s default on slower CI
runners — the suite went red on exactly that test after the font
commit.
This commit is contained in:
Paul Nothaft
2026-07-19 22:35:44 +02:00
committed by GitHub
parent 613133c29c
commit 60cdd07085
16 changed files with 547 additions and 3 deletions
+1
View File
@@ -13,5 +13,6 @@ require('./slideshow')(router);
require('./resets')(router);
require('./archiveBulk')(router);
require('./logo')(router);
require('./qr')(router);
module.exports = router;
+183
View File
@@ -0,0 +1,183 @@
// Gallery QR codes (#836): QR image (PNG/SVG) for the event's share link and
// print-ready PDF templates (table card A6, poster A4) built with pdfkit.
// Registered after ./logo in ./index.js — all routes are '/:id/...' literals,
// so registration order relative to the other sub-modules is not sensitive.
const path = require('path');
const QRCode = require('qrcode');
const PDFDocument = require('pdfkit');
const { db } = require('../../database/db');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const { requireEventOwnership } = require('../../middleware/ownership');
const { buildShareLinkVariants, getEventShareToken } = require('../../services/shareLinkService');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
// Caption under the QR on the print templates, in the admin's UI language
// (passed as ?lang= by the frontend). Mirrors the 8 gallery locales.
const PRINT_CAPTIONS = {
en: 'Scan to view & share your photos',
de: 'Scannen, um Fotos anzusehen & zu teilen',
es: 'Escanea para ver y compartir tus fotos',
fr: 'Scannez pour voir et partager vos photos',
nl: 'Scan om fotos te bekijken & te delen',
pt: 'Escaneie para ver e compartilhar suas fotos',
ru: 'Отсканируйте, чтобы посмотреть и поделиться фото',
sl: 'Skenirajte za ogled in deljenje fotografij',
};
// A6 = 298 x 420 pt, A4 = 595 x 842 pt (pdfkit default unit).
const TEMPLATES = {
'table-card': { size: [298, 420], qrSize: 180, titleSize: 16, captionSize: 10, urlSize: 7 },
poster: { size: 'A4', qrSize: 360, titleSize: 28, captionSize: 16, urlSize: 10 },
};
// Bundled COMPLETE IBM Plex Sans (1019 glyphs: Latin + Cyrillic + Greek,
// verified via fontkit cmap) — pdfkit's built-in Helvetica is WinAnsi-only
// and silently drops e.g. Cyrillic event names, and the pre-existing
// assets/fonts/IBM-Plex-Sans/ files are 270-glyph Latin subsets with the
// same gap (codex review of #847). OFL license alongside the files.
const FONT_BOLD = path.join(__dirname, '../../../assets/fonts/IBM-Plex-Sans-Full/700.ttf');
const FONT_REGULAR = path.join(__dirname, '../../../assets/fonts/IBM-Plex-Sans-Full/400.ttf');
// A same-origin admin GET carries no Origin header, so the frontend passes
// window.location.origin explicitly. Only accept a plain http(s) origin.
const ORIGIN_RE = /^https?:\/\/[^\s/]+$/i;
// Configured FRONTEND_URL defaults to localhost on unconfigured installs —
// a QR pointing there is unusable on any other device (codex review of #847).
const LOCAL_BASE_RE = /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/i;
async function loadShareUrl(eventId, requestOrigin) {
const event = await db('events').where({ id: eventId }).first();
if (!event) return { event: null, shareUrl: null };
// Single source of truth is the STORED share_link — exactly what the
// ShareLinkCard displays and the admin copies. Rebuilding from the
// current slug/token/short-URL setting can diverge for legacy absolute
// links or events created under a different short-URL setting, and a
// printed QR encoding a different URL than the card is a permanent
// mistake (codex review of #847, confirmation round). Only when no
// share_link is stored do we fall back to rebuilding it.
let link = event.share_link;
if (!link) {
const shareToken = getEventShareToken(event);
if (!shareToken) return { event, shareUrl: null };
({ shareLinkToStore: link } = await buildShareLinkVariants({ slug: event.slug, shareToken }));
}
const origin = typeof requestOrigin === 'string' && ORIGIN_RE.test(requestOrigin)
? requestOrigin.replace(/\/$/, '')
: null;
// Absolute + reachable → use as-is; loopback-absolute → re-anchor its
// path; relative → absolutize. Mirrors the frontend's buildShareLinkUrl.
if (/^https?:\/\//i.test(link) && !LOCAL_BASE_RE.test(link)) {
return { event, shareUrl: link };
}
let sharePath = link;
if (/^https?:\/\//i.test(link)) {
try { const u = new URL(link); sharePath = `${u.pathname}${u.search}`; } catch { /* keep as-is */ }
}
// Bare stored values (quote-/contract-converted events persist the raw
// token) resolve as /gallery/<token> — mirroring the frontend's
// buildShareLinkUrl exactly (codex review of #847, final round).
if (!sharePath.startsWith('/')) sharePath = `/gallery/${sharePath}`;
if (origin) return { event, shareUrl: `${origin}${sharePath}` };
const frontendBase = await getFrontendBaseUrl();
return { event, shareUrl: frontendBase ? `${frontendBase}${sharePath}` : sharePath };
}
module.exports = (router) => {
// QR image for the gallery share link.
router.get('/:id/qr', adminAuth, requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const { event, shareUrl } = await loadShareUrl(req.params.id, req.query.origin);
if (!event) return errorResponse(res, 'Event not found', 404);
if (!shareUrl) return errorResponse(res, 'Event has no share link', 409);
const format = req.query.format === 'svg' ? 'svg' : 'png';
const download = req.query.download === '1';
const disposition = `${download ? 'attachment' : 'inline'}; filename="qr-${event.slug}.${format}"`;
if (format === 'svg') {
const svg = await QRCode.toString(shareUrl, { type: 'svg', margin: 4 });
res.set('Content-Type', 'image/svg+xml');
res.set('Content-Disposition', disposition);
return res.send(svg);
}
const width = Math.min(Math.max(parseInt(req.query.size, 10) || 600, 128), 2048);
const png = await QRCode.toBuffer(shareUrl, { type: 'png', width, margin: 4 });
res.set('Content-Type', 'image/png');
res.set('Content-Disposition', disposition);
return res.send(png);
} catch (error) {
logger.error('Failed to generate gallery QR code:', error);
return errorResponse(res, error, 500, 'Failed to generate QR code');
}
});
// Print-ready PDF (table card / poster) with QR + event name + caption.
router.get('/:id/qr-print', adminAuth, requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const { event, shareUrl } = await loadShareUrl(req.params.id, req.query.origin);
if (!event) return errorResponse(res, 'Event not found', 404);
if (!shareUrl) return errorResponse(res, 'Event has no share link', 409);
const templateKey = TEMPLATES[req.query.template] ? req.query.template : 'table-card';
const tpl = TEMPLATES[templateKey];
const caption = PRINT_CAPTIONS[req.query.lang] || PRINT_CAPTIONS.en;
const qrPng = await QRCode.toBuffer(shareUrl, { type: 'png', width: tpl.qrSize * 3, margin: 4 });
const doc = new PDFDocument({
size: tpl.size,
margins: { top: 0, bottom: 0, left: 0, right: 0 },
info: { Title: `PicPeak QR — ${event.event_name}` },
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `attachment; filename="qr-${templateKey}-${event.slug}.pdf"`);
doc.pipe(res);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const contentTop = pageHeight * 0.12;
// Fixed vertical layout: the title gets a bounded two-line region with
// ellipsis so an arbitrarily long event name can't push the QR/caption
// over the footer or off the page (codex review of #847). All positions
// below derive from constants, never from doc.y.
const titleBlockHeight = tpl.titleSize * 2.6;
doc.font(FONT_BOLD).fontSize(tpl.titleSize).fillColor('#1a1a1a')
.text(event.event_name, pageWidth * 0.1, contentTop, {
width: pageWidth * 0.8,
align: 'center',
height: titleBlockHeight,
ellipsis: true,
});
const qrX = (pageWidth - tpl.qrSize) / 2;
const qrY = contentTop + titleBlockHeight + tpl.titleSize * 0.5;
doc.image(qrPng, qrX, qrY, { width: tpl.qrSize, height: tpl.qrSize });
doc.font(FONT_REGULAR).fontSize(tpl.captionSize).fillColor('#333333')
.text(caption, pageWidth * 0.1, qrY + tpl.qrSize + tpl.captionSize, { width: pageWidth * 0.8, align: 'center' });
doc.font(FONT_REGULAR).fontSize(tpl.urlSize).fillColor('#888888')
.text(shareUrl, pageWidth * 0.05, pageHeight - pageHeight * 0.07, {
width: pageWidth * 0.9,
align: 'center',
height: pageHeight * 0.06,
ellipsis: true,
});
doc.end();
} catch (error) {
logger.error('Failed to generate QR print PDF:', error);
if (!res.headersSent) return errorResponse(res, error, 500, 'Failed to generate QR print PDF');
return res.end();
}
});
};