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
@@ -0,0 +1,122 @@
/**
* HTTP tests for the gallery QR endpoints (#836):
* GET /api/admin/events/:id/qr (PNG / SVG)
* GET /api/admin/events/:id/qr-print (table-card / poster PDF)
* Same real-SQLite harness as adminEvents.smoke.test.js.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-qr-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-qr-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'QR Test Wedding',
event_date: '2026-05-29',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('admin event QR endpoints', () => {
let db; let cleanup; let app; let adminId; let token;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => { await db('events').del(); });
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
it('401s without an admin token', async () => {
const eventId = await insertEvent(db, adminId);
const res = await request(app).get(`/api/admin/events/${eventId}/qr`);
expect(res.status).toBe(401);
});
it('returns a PNG QR by default', async () => {
const eventId = await insertEvent(db, adminId);
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`)).buffer();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
// PNG magic bytes
expect(res.body.slice(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
});
it('returns an SVG QR when requested', async () => {
const eventId = await insertEvent(db, adminId);
// supertest doesn't text-parse image/svg+xml — buffer and decode manually.
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?format=svg`)).buffer();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/image\/svg\+xml/);
expect(Buffer.from(res.body).toString('utf8')).toContain('<svg');
});
it('sets attachment disposition with download=1', async () => {
const eventId = await insertEvent(db, adminId);
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?download=1`)).buffer();
expect(res.headers['content-disposition']).toMatch(/^attachment/);
});
// 30s: the print PDFs embed the full IBM Plex Sans TTFs (~200 KB each) —
// font parsing + subsetting exceeds jest's 5s default on slower CI runners.
it.each(['table-card', 'poster'])('renders the %s print PDF', async (template) => {
const eventId = await insertEvent(db, adminId);
const res = await auth(
request(app).get(`/api/admin/events/${eventId}/qr-print?template=${template}&lang=de`)
).buffer();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('application/pdf');
expect(res.body.slice(0, 4).toString()).toBe('%PDF');
}, 30000);
it('409s when the event has no share link', async () => {
// events.share_link is NOT NULL — an empty string is the closest real-world
// "no share link" shape (no token extractable from it either).
const eventId = await insertEvent(db, adminId, { share_link: '', share_token: null });
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`));
expect(res.status).toBe(409);
});
it('404s for a non-existent event', async () => {
const res = await auth(request(app).get('/api/admin/events/999999/qr'));
expect(res.status).toBe(404);
});
});