Two SSR-OG injection bugs reported by @alexvaltchev. Both made his link
previews fall back to the brand logo + site-wide tagline instead of the
event-specific name/photo, even though the bot UA was hitting our
already-existing OG handler. He compensated with a Cloudflare Worker as
SSR middleware — which then created bug 3 below (og:image at the
auth-gated /api/.../hero/ path, not the public /og/.../cover one), so
Instagram never rendered the image either.
## Bug A — slideshow URLs miss the OG handler entirely
`/gallery/<slug>/show/<token>` has 3 segments after `/gallery/`. The OG
route was wired only at `/gallery/:slug/:token?` (1-2 segments), so
slideshow links fell through to the SPA-catchall `/gallery/*` and never
invoked the OG handler at all. Added a second route handler for the
3-segment slideshow shape, sharing the same intercept middleware so a
recognised social crawler still gets the rich preview.
## Bug B — share-token-only URLs resolve to nothing
`/gallery/<32-char-share-token>` (the form produced when migration 525's
short-URLs option strips the event slug) routes to the OG handler with
`slug=<token>`. resolveSlug then queries `events.slug = <token>`, which
never matches because the token is in a separate `share_token` column.
Result: falls through to the "no event found" branch and serves the
generic site-wide OG.
Fix: when the slug shape matches a 32-char hex AND the slug lookup
missed AND no redirect rule applies, try `events.share_token = slug` as
a final fallback. Real slugs are kebab/dot/underscore mixes, never pure
32-hex, so the extra DB roundtrip is gated to only fire for the
token-shaped URL.
## Tests
3 new tests in galleryOgService.shareImage.test.js using non-entropy
32-hex fixtures (deliberately zero-padded to avoid tripping
GitGuardian's Generic High Entropy Secret detector while still
matching the route's /^[a-f0-9]{32}$/i shape check):
- share-token slug resolves via the share_token column (alex's case)
- malformed/expired 32-hex token returns the site-wide fallback (no leak)
- non-hex slugs skip the share_token query entirely (hot-path cost guarded)
All 14 tests in the file pass.
## Out of scope here (separate follow-up)
- Issue 2 (Instagram og:image) — alex-side CF Worker bug pointing
og:image at /api/gallery/<slug>/hero/<id>, which requires gallery
auth. PicPeak already has the right unauthenticated path
(/og/gallery/<slug>/cover) gated by events.og_image_share_enabled
per-event opt-in (#474). Documented in the issue reply.
- Issue 3 (URL shortener with custom names) — real feature request,
meaningfully different from the existing #525 short-URLs option that
just strips the slug. Designing separately.
Surfaces release highlights to admins, sourced from the GitHub release notes
(no AI at runtime). Bullets are written once per release in CI via GitHub Models
(see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app
reads that block and falls back to the changelog's "### Features" for releases
without it — so it works against today's releases immediately.
- backend utils/whatsNew.parseWhatsNew(body): curated block else Features
section, strips scope/PR-links, de-dups, caps at 8 (tested).
- GET /admin/system/updates/whatsnew: highlights for every version moved
through since the per-instance marker (whatsnew_last_seen_version); fresh
installs self-anchor silently. Best-effort, never errors.
- POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance).
- /admin/system/updates also returns latestHighlights for the teaser.
- Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on
the dashboard via adminService; UpdateNotification shows a "New features
include:" teaser. i18n de/en. No migration (uses app_settings).
Repo transferred from the-luap/picpeak → PicPeak/picpeak. Docker images
publish to ghcr.io/picpeak/picpeak/{backend,frontend} (lowercase, per the
GHCR canonical form computed by docker-build.yml's `${GITHUB_REPOSITORY,,}`).
Sweep covers:
- docker-compose.production.yml + Dockerfiles → new image registry path
- README, CONTRIBUTING, SECURITY, SIMPLE_SETUP, scripts/picpeak-setup.sh
→ new GitHub URLs
- Update-check / release-notes services (updateCheckService,
environmentService, updateNotificationService, adminSystem,
UpdateNotification, githubReleaseUrl) → GitHub API + tag URLs use the
canonical PicPeak/picpeak path
- Issue templates + README-DOCKER + workflow README → updated package URLs
- One commit-context comment in migrations/090 + customerAccountsService
CHANGELOG.md is intentionally untouched (historical release entries are
immutable; GitHub auto-redirects the old URLs indefinitely).
CLAUDE.md keeps the bare `(the-luap)` reference — that's the maintainer's
personal handle, not a repo URL.
22 files, 48/48 line swaps (every change is a 1:1 URL replacement).
Per request, keep the dashboard to four tiles rather than adding a fifth: the
"Revenue · last 365 days" tile is now clickable and toggles in place between
the trailing-365-day window and calendar year-to-date (since Jan 1).
- adminDashboard: new calendar-year cutoff + revenue.calendarYearMinor (same
cash-basis paid_at window logic as the existing trio).
- StatCard gains an optional onClick (renders as a button); the year tile uses
it, with a "Tap to switch window" hint for discoverability.
- bills.service CrmOverviewStats.revenue gains calendarYearMinor.
Manual/monthly-cadence customers accumulate logged hours into one running
draft invoice (is_monthly_draft, migration 128). That draft gets a real
invoice number and stamps the hours ("Billed: R-2026-0026"), but listInvoices
hid is_monthly_draft rows from the main list — so the invoice looked lost even
though it existed on the customer's monthly-queue card. It also carried status
'scheduled' despite never auto-sending on manual cadence, reading misleadingly
as "Scheduled".
- Bills list now opts into drafts via a new `includeDrafts` query param
(GET /admin/invoices → listInvoices includeMonthlyDrafts). Pickers/sub-lists
that reuse billsService.list leave it off, so they're unaffected.
- Draft rows render a distinct "Draft" badge instead of "Scheduled"
(transformInvoice already exposes isMonthlyDraft).
- The hours "Billed: R-…" chip now links straight to its invoice.
- i18n: bills.status.draft (de "Entwurf", en "Draft").
eventReminderService used bare boolean literals in its knex .where() calls
(events.is_active/is_archived/event_reminder_disabled and the assigned-
customer c.is_active), instead of the codebase's formatBoolean() convention
(utils/dbCompat). On SQLite, booleans are stored as 0/1, so a bare `true`
relies on knex's coercion rather than the explicit helper every other service
uses — the maintainer flagged this twice (#674, #679). Wrap all four.
Per-line totals are each rounded to the cent before the net is summed, so
a long time-based invoice can drift a few Rappen from qty × rate — e.g.
68 h × 32.25 = 2193.00, but the 21 rounded line totals sum to 2193.02. This
is the standard "sum of rounded lines" convention (Stripe/QuickBooks/Xero
do the same) and it foots, but some issuers want the total to match the
customer's arithmetic.
New per-issuer setting `crm_invoice_round_total` (default OFF, no migration —
read via getAppSetting with a false default). When on, the create paths store
the full-precision net rounded ONCE (cleanNetMinor), and the drift is shown to
the reader as an explicit "Rundung" row:
Betrag Netto 2'193.02 (= Σ visible line totals, still foots)
Rundung -0.02
Gesamtbetrag 2'193.00
- New util src/utils/invoiceRounding.js (cleanNetMinor) mirrors the
migration-119 hierarchy (priced sub-items override their parent) but sums
at full precision; rate-agnostic, so mixed hourly rates reconcile to one
clean net. Single document-level VAT rate ⇒ one Rundung row.
- computeTotals (quotes) + createInvoice + payload-preview gain the toggle.
- Render contexts derive the row as storedNet − Σ(line totals); legacy/off
documents have equal values ⇒ adjustment 0 ⇒ byte-identical output.
Suppressed on Storno/Mahnung (negated net + sign-flipped lines).
- Storno/tax-report stay correct: both use the stored net scalar, which is
the clean value (createStorno negates net_amount_minor; it never re-sums).
- pdf-i18n: totals_rounding in all 6 locales (de/en/fr confident; nl/pt/ru
machine-translated — flag for native review).
- Frontend: toggle on Settings → CRM (Invoices), default off.
Tests: backend/__tests__/utils/invoiceRounding.test.js (real 68h invoice,
mixed rates, discounts, sub-item hierarchy, no-op case).
Before drawing the line-items table, the renderer inflated page 1's bottom
margin to reserve room for the bottom-pinned totals block, but the `finally`
restored it on whichever page the table *ended* on — leaving page 1
permanently short on any multi-page document. On long invoices and quotes
this caused:
- the table to break far too early (only ~6 items on page 1, large blank
gap beneath)
- the page-number stamp to land below page 1's phantom bottom margin,
auto-paginating a stray blank trailing page and desyncing the
"Seite X von Y" labels (page 1 unnumbered, the blank page labelled
"Seite 1 von N")
Let the table paginate with the document's normal margins so each page fills
to the bottom; the existing desiredTotalsY check already advances to a fresh
page when the last item row would collide with the pinned totals block.
Also suppress the IBAN block under the totals when a Swiss QR-bill slip is
appended: the slip already prints the account/IBAN in human-readable form,
so it was pure duplication. The EPC QR path keeps the block (its QR lives on
a trailing page, so on-page bank details still help).
The customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
(default 15) before it locks, and the public page promises exactly that. But the
booking workflow fired on the FIRST accept click and immediately converted the
quote (status -> 'converted'), so a decline within the window was rejected
('Quote cannot be responded to in status converted') — the grace period was dead
on arrival.
recordResponse / adminAcceptQuote now DEFER the workflow emit while the toggle
window is open; the new scheduler sweep finalizeQuoteResponses fires the FINAL
status once response_locked_at passes (idempotent via the new
quotes.workflow_response_emitted_at column, migration 149). A response recorded
with the window already closed (0-min window, or admin decline which locks
immediately) still emits inline. So toggling accept->decline->accept inside the
window converts at most once, for the final state, after the customer's grace
period — and a plain decline never converts.
Trade-off: with the hourly CRM scheduler, the booking flow now starts up to ~1h
after the window locks instead of instantly. Acceptable — the flow gates on admin
review anyway, and the alternative (graph-level wait) wouldn't reach already-
enabled built-ins (admin_toggled_at blocks re-seed).
Adds a finalize sweep test (deferred while open, fires + stamps once locked,
idempotent).
Two entry points for event creation were missing customer notifications, both
discovered while triaging @Rekoo-PS's report that "API created events" don't
send WhatsApp after #649/#650 landed.
POST /api/v1/events (the OpenAPI-spec'd bearer-token API at v1/events.js):
- gallery_created email was NEVER queued — only the webhook fired.
- WhatsApp was NEVER queued either.
POST /api/events (legacy admin-auth route at routes/events.js):
- gallery_created email was queued, but WhatsApp was not.
- customer_phone wasn't read from the body at all.
Both routes now mirror the adminEvents.js create-and-publish path: best-effort
queues that never block the API response, gated on customer_email / customer_phone
presence and the global event_phone_field_enabled toggle for the phone field.
The webhook subject from POST /api/events now also includes customer_phone, so
downstream integrations get the same shape as the v1 API.
No schema change. No migration. customer_phone column already exists on events
(migration 080). WhatsApp config + template_language + template_params resolve
through the existing queue processor.
A quote with no explicit payment timing falls back to a single after_delivery
installment. spawnInstallmentInvoices marked those 'pending_delivery' even in
hold mode, so the booking flow's send_document -> sendInvoice threw 'Cannot send
invoice with status pending_delivery', the run failed, and no invoice email went
out (the symptom: approve the quote->invoice flow, receive nothing).
In hold mode the flow's review gate + explicit send_document IS the delivery
release, so a held invoice is always 'scheduled' (editable + sendable) regardless
of trigger; scheduled_send_at stays null so the scheduler never auto-sends it.
Non-hold after_delivery invoices keep 'pending_delivery' as before.
Adds a regression test (default after_delivery term -> draft -> scheduled+null).
These were the last guard-stubbed actions — offered in the builder palette but
refused on enable. Now all three are real, backed by existing converters:
- prepare_gallery: alias of prepare_event (a gallery IS an event in picpeak).
- reserve_date: convertToEvent({ skipInvoices: true }) — a pure draft date hold
with no money documents (new skipInvoices option on convertToEvent).
- prepare_quote: createQuote (customer entity) or duplicateQuote (quote entity),
producing a status='draft' quote; idempotent via ctx.vars.preparedQuoteId.
With no stubs left, the enable-guard switches from a hardcoded DOCUMENT_ACTIONS
list to a registry lookup: an action node whose config.action has no registered
handler is unimplementable. This can't drift from what the engine can run and
also catches typo'd/future actions. (Fixes the enable-route node mapping to
carry node.type so the action-node filter matches.)
Extends the single-connection SQLite in-trx deadlock fixes to the quote-create
path (prepare_quote runs unattended): nextQuoteNumber reads getAppSetting
through trx, createQuote logs via trx and hoists its hasColumnCached schema-drift
checks before the transaction.
Adds tests for reserve_date (no invoices), prepare_quote (draft, no deadlock),
and registry coverage; retargets the enable-guard refusal test at a genuinely
unregistered action. Full backend suite: 985 passed, 1 skipped.
The booking_full / booking_simple flows go prepare_event -> prepare_invoice,
but prepare_event was still a guard-stub, so enabling either flow returned
409 'uses actions that aren't implemented: prepare_event'.
prepare_event now calls convertToEvent({ hold: true }): convertToEvent already
creates the event as is_draft=true AND schedules its invoices, so this creates
those invoices on HOLD (scheduled_send_at NULL) and stashes their ids in
ctx.vars.preparedInvoiceIds. The downstream prepare_invoice already short-
circuits on a populated preparedInvoiceIds, so it ADOPTS the event's held
invoices instead of calling convertToInvoiceOnly again (which would both
double-create and throw ALREADY_CONVERTED_TO_EVENT). The review gate, the
wait-until-event-date, and send_document then issue those same invoices.
send_document(event)=publish is intentionally left a graceful skip — the
gallery is published manually after photos are uploaded, not auto-published
on an empty draft.
convertToEvent gains the same single-connection SQLite deadlock fixes as
convertToInvoiceOnly (getAppSetting reads through trx; logActivity moved after
commit) since prepare_event runs unattended, returns invoiceIds (incl. the
idempotent already-converted re-entry, which recovers them by event_id), and
removes prepare_event from the enable-guard list.
Adds a convertToEvent hold-mode test (draft event + held invoices + quote
linkage) and updates the enable-guard test to a still-stub action
(prepare_gallery). Full backend suite: 982 passed, 1 skipped.
Implements the draft-seam booking cutover so the booking_invoice_only flow
becomes enableable. The booking flows trigger on quote.accepted, so the run
entity is the quote:
- prepare_invoice: convertToInvoiceOnly({draft:true}) creates the invoice(s)
on HOLD (scheduled_send_at NULL, status stays 'scheduled') so the scheduler
never auto-sends before the review gate; crash-recovery recovers drafts by
the quote's deal_uuid. Stores ids in ctx.vars.preparedInvoiceIds.
- prepare_contract: createFromQuote (idempotent via converted_contract_id).
- send_document: dispatches the prepared draft (invoice -> sendInvoice each id,
contract -> sendContract).
- resolveActor: quote creator -> workflow creator -> first admin.
- prepare_contract/prepare_invoice/send_document removed from the enable-guard
list; prepare_event/prepare_quote/prepare_gallery/reserve_date still guarded,
so booking_full/booking_simple stay blocked until the event-path increment.
Fixes a latent single-connection SQLite deadlock these unattended paths would
hit: getAppSetting/logActivity/adminActor read or write the global db, which
deadlocks when issued inside an open knex transaction. Thread the active trx
through getAppSetting, logActivity, nextInvoiceNumber, nextContractNumber, the
spawnInstallmentInvoices audit log, and hoist adminActor before createFromQuote's
transaction. convertToInvoiceOnly now logs after commit and returns invoiceIds.
Adds bookingCutover integration test (hold-mode null send-at, normal scheduled
contrast, contract path no-deadlock) and a route test that the now-implemented
booking invoice actions can be enabled.
Publishing a gallery with no inline customer_email but assigned customer
account(s) previously sent nothing (the dialog said "no notification"). Now the
publish route falls back to the existing customer_gallery_assigned "your
galleries" email (sent per assigned active account in their preferred language)
so registered customers learn the gallery is available. Inline-email path
(gallery_created) is unchanged.
The publish dialog now reflects this: with an inline email it notifies that
address; with only assigned accounts it says the account(s) will be notified;
with neither, the button is just "Publish" (no false notify promise). Exports
notifyCustomerOfNewAssignments; EN/DE strings added.
When an event has no inline customer_email/host_email but has customer
account(s) assigned (event_customer_assignments), the pre-event reminder now
sends to those registered customers instead of skipping with no_recipient.
Recipients sent to an assigned account are queued WITHOUT eventId so the
language resolver uses the customer's preferred_language (vs the event's
language for inline-email sends). Applies to both the flow path
(sendReminderForEvent) and the legacy pass. The gallery-ready mail deliberately
does NOT fall back to accounts — only the reminder does. Test covers the
no-inline-email + assigned-customer case.
Language priority is event.language → customer preferred_language → app default
→ … → en, but it was keyed on email_data.eventId, which only queueEmail injects.
Direct email_queue inserts (e.g. the gallery-publish "notify customer" path) set
the event_id COLUMN but not email_data.eventId, so those mails skipped
event.language and fell through to the default — e.g. a gallery-ready mail in EN
while the same event's pre-event reminder (sent via queueEmail) was DE.
The processor now backfills emailData.eventId from the authoritative event_id
column before rendering, so every send path resolves language from the event
consistently.
composePayload pre-formatted event_date to DD.MM.YYYY, but emailProcessor runs
date variables through formatDate(value, language) — new Date("25.06.2026")
can't parse → the email rendered "Invalid Date". Pass the raw event_date and let
the processor localise it, matching the expiry mailer's contract. Pre-existing
in the migration-143 composePayload (dormant while the legacy pass was gated
off); surfaced once the pre_event_email flow ran.
Replaces the one-shot guarded POST with the maintainer's intended end-state: the
webhook node now references a CONFIGURED webhook subscription (Settings →
Webhooks) and enqueues a real webhook_deliveries row via
webhookService.enqueueForWebhook. Delivery then rides the existing worker
pipeline, inheriting — not reimplementing — per-delivery SSRF re-validation
(validateExternalUrl / GHSA-wmjx-pc37-272r), HMAC signing with the
subscription's secret, retry/backoff, and the deliveries audit log.
- webhookService.enqueueForWebhook(webhookId, eventType, data): enqueue for one
active subscription, bypassing fire()'s event-type matching. No schema change.
- webhook action: config.webhookId; unset/missing/inactive → observable skip;
dry-run does not enqueue. event_type = workflow.<trigger>.
- Editor: webhook node config is now a subscription dropdown (was a raw URL),
fed by the admin webhooks list, with a hint pointing to Settings → Webhooks.
- EN/DE strings; test asserts enqueue + dry-run no-op + inactive skip.
Second-review loose end: the `webhook` node type passed validation but had no
registered handler → engine dispatched to registry.getAction('webhook') →
undefined → every run silently skipped. An enabled webhook flow no-op'd.
Register a real `webhook` action (covers both the webhook node type and the
"Call a webhook" action). It POSTs the run context to config.url, guarded by
validateExternalUrl — the same NAT64/private-range SSRF protection the webhook
delivery worker uses (GHSA-wmjx-pc37-272r) — with no redirects and a timeout,
unless WEBHOOK_ALLOW_PRIVATE_URLS=true (local-dev opt-out). Missing URL /
rejected URL / network error record an observable skipped step, not a crash.
So the action is now implemented → it passes the enable guard legitimately.
Test covers dry-run, missing-url, and metadata-IP (169.254.169.254) rejection.
Renaming an event type's slug_prefix is editable in the UI but previously
orphaned everything keyed on the old slug: existing events/quotes (their
event_type) detached, and the authored per-type pre-event reminder template
(event_reminder_<slug>) was left behind → reminders fell back to default.
updateEventType now cascades atomically when the slug changes: re-points
events.event_type + quotes.event_type old→new and renames the
event_reminder_<old> template to <new> (guarded so it never clobbers an existing
target). So a photographer can rename a type to e.g. "concert" and the edited
subject/body follow. Column check resolved before the transaction (avoids the
SQLite global-read-in-trx deadlock). Tests cover the cascade + no-clobber.
The reminder template family (prefix) is now chosen on the notify_pre_event
block via config.templateGroup (default 'event_reminder'); within that group the
exact template is still auto-resolved per event type:
<group>_<eventType> if authored → else <group>_default
So an admin can point a flow at a different reminder family, while wedding/
birthday/… routing and the catch-all fallback stay automatic. resolveTemplateKey
now takes (eventType, group) and tolerates a trailing "_" on the group.
Editor: notify_pre_event (+ the gallery notify actions) added to the action
dropdown, with a "Reminder template group" field and hint. Seed sets
templateGroup='event_reminder' on the built-in (v4). EN/DE strings. Tests cover
the per-type / group-default resolution.
The reminder query joined customer_accounts on events.customer_account_id — a
column the events table doesn't have (events store the recipient inline as
customer_email/host_email, like the gallery emails). So the query threw, the run
failed, and no pre-event email went out for an event that has an email but no
CRM customer account. Latent in the legacy pass (gated off by default); surfaced
the moment the pre_event_email flow ran notify_pre_event.
Both runEventReminderPass and sendReminderForEvent now read the recipient from
the event's own columns (customer_email || host_email, name from
customer_name || host_name) via SELECT events.* — no join, safe on installs
predating the customer_email column. Regression test covers an event with a
direct email and no customer account.
Concern #2: switch eq/neq to ===/!== (drop the eslint-disable); a filter
{value:0} no longer matches false/''/null. Comment corrected — no implicit
type normalisation; filter authors match the payload type.
- gate decision with no matching edge → run fails (not silent done)
- enabled-based mutex: legacy reminder pass stands down only when the flow is on
- built-ins now seeded disabled (v6/v2/v3); re-seed flips never-touched defaults
but preserves an admin_toggled_at-owned flow
- route: rejects unknown node type; refuses enabling a flow with unimplemented actions
Review concerns #1/#2/#3/#5:
- validateGraph whitelists node types (rejects a typo'd 'actoin' that would
no-op every cycle).
- Caps graph size: max 200 nodes / 500 edges / 16KB per-node config — a
workflows.manage user can't DoS the DB with a giant graph.
- Refuses to enable (create/update/PATCH) a flow whose graph references
unimplemented stub actions (the booking prepare_*/send_document), with a
clear 409, so an admin can't enable a flow that silently drops the work.
- Stamps admin_toggled_at on admin enable/disable/edit (sentinel for the seeder).
matchFilter strict-equality fix lives in the engine commit.
Per review: the four cutover built-ins (dunning, gallery_expiring,
gallery_expired, pre_event_email) now ship enabled:false. The mutual-exclusion
guards revert to ENABLED-based (isBuiltinFlowActive, not existence) so the
legacy paths keep running until the admin enables a built-in — enabling cuts
over, disabling reverts to legacy (fixes concern #4's "disable = silent dark"
foot-gun; no automation goes dark on upgrade).
admin_toggled_at sentinel (migration 148) marks admin ownership; the boot
re-seeder applies a shipped default-flip (enabled→disabled) only to
never-touched built-ins and never overwrites an admin's enable/disable/edit
(nit #1). SEED_VERSIONs bumped so the disabled default propagates.
Nit: applyReminder unlinks the just-rendered Mahnung PDF if queueEmail throws
(no orphan file).
Blocker #1: GET /workflow-approvals/:token/:action no longer mutates. Email
clients + security scanners (Outlook Safe Links, Gmail, Proofpoint, AV
link-checkers) GET links before the human clicks, which previously advanced a
payment-confirm gate silently. GET now renders a confirm/deny interstitial via
a new read-only peekApproval(); only POST calls actByToken.
Blocker #2: a gate decision with no matching edge now failRun()s instead of
finishRun(). resumeRun matches the decision handle EXACTLY (no fall-back to
outEdge's sole-edge heuristic), so a 'deny' with only a 'confirm' edge fails
loudly in run history instead of taking the confirm path / a green 'done'.
A quote can now choose which flow runs on acceptance instead of every enabled
quote.accepted flow firing. Migration 147 adds quotes.booking_workflow_id; the
editor shows a "Booking workflow (on acceptance)" dropdown listing the
quote.accepted flows (workflow-engine flag only); emitQuoteEvent passes it as
the new emitWorkflowEvent targetWorkflowId so ONLY the picked flow runs (still
gated on enabled + trigger match → a disabled/None selection runs nothing).
Adds the booking_invoice_only built-in (quote.accepted → prepare invoice →
review gate → send; no event/gallery, no wait), the variant requested for
shoots billed without an online gallery. Disabled stub like the other booking
flows until the prepare_*/send_document cutover.
Tests: targetWorkflowId runs only the selected flow; invoice-only built-in has
no wait/prepare_event.
Implements the hybrid scope agreed on in #663: two native adapters
(Umami + Rybbit) for trackers we'd keep maintained, plus a Custom
script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4,
GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible
native, deeper metrics) explicitly deferred until someone asks.
## Architecture
**Backend `services/trackers/`**:
- `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` →
`{ desktop, mobile, tablet } | null`. Null = route falls back to
access_logs heuristic.
- `umamiAdapter.js` — extracted from the `services/umamiClient.js`
that landed in #662. Same 10 test contract preserved.
- `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension=
device` with Bearer auth, accepts both bare-array and `{data:[...]}`
envelope variants, tolerates `sessions`/`visitors`/`value`/`count`
metric keys.
- `customScriptSanitiser.js` — sanitize-html with a tracker-tight
allowlist (`<script>` / `<noscript>` / `<link rel=preconnect|
dns-prefetch>` / `<meta>`). Strips event-handler attributes,
`javascript:` and `data:` URLs.
- `index.js` factory: `resolveAdapter()` reads
`analytics_tracker_provider` setting → dispatches. Back-compat:
when provider is unset, infers `umami` from the legacy
`analytics_umami_enabled` flag so #662 installs keep working
without an admin touching settings.
**Backend routes**:
- `adminDashboard.js /analytics`: now goes through `resolveAdapter()`.
Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js`
and its test file deleted (replaced by the adapter shape).
- `adminSettings.js PUT /analytics`: validates the new
`analytics_tracker_provider` enum, sanitises any incoming
`analytics_custom_head_html` on save via the sanitiser. Masks
the new `analytics_rybbit_api_key` on every GET — same pattern as
Umami's API key and recaptcha secret.
- `publicSettings.js`: emits `analytics_tracker_provider`,
`rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and
the pre-sanitised `analytics_custom_head_html` (only when
provider=custom). Legacy `umami_*` fields stay for back-compat.
**Frontend**:
- `analytics.service.ts` reworked into a provider-aware shape.
`initialize({provider, ...config})` dispatches to Umami /
Rybbit / Custom / None. `track()` calls dispatch to
`window.umami.track` / `window.rybbit.event` / no-op based on
the loaded provider.
- `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider`
from public-settings and routes to the right `initialize` call.
Legacy `umami_enabled`-based path preserved as fallback when the
new field is missing.
- `AnalyticsTab.tsx` (Settings → Analytics) reworked with a
"Provider" dropdown switching between None / Umami / Rybbit /
Custom panels. Each panel renders its own config fields; Custom
panel surfaces an explicit CSP-reminder banner.
- `useSettingsState.ts` shape extended with `tracker_provider`,
`rybbit_url`/`rybbit_website_id`/`rybbit_api_key`,
`custom_head_html`. Save mutation keeps `umami_enabled` in sync
with `tracker_provider==='umami'` for back-compat with downstream
consumers (publicSettings shape, embedded iframe).
- `publicSettings.service.ts` type extended.
**i18n**: EN + DE for the provider heading + description + dropdown
options + Rybbit fields + Custom HTML field + CSP warning.
## Custom mode — script execution caveat
When the gallery `<head>` receives the custom HTML, simply assigning
innerHTML to a container element wouldn't execute the embedded
`<script>` tags (per the HTML spec, dynamically-inserted scripts via
innerHTML are non-running). `analytics.service.ts:120-130` re-creates
each `<script>` element manually so the browser actually evaluates
it. Non-script nodes (link, meta, noscript) move in directly.
## Tests
**Backend** (42 cases, all pass locally):
- `umamiAdapter.test.js` (10) — pinned from the original
`umamiClient.test.js`: missing-config / URL shape / encoding /
payload normalisation / `laptop`→`desktop` / unknown buckets /
empty / non-2xx / invalid JSON / network error.
- `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit:
bare-array + envelope payload, `sessions`/`visitors`/`dimension`
key tolerance, encoding, failure modes.
- `trackerFactory.test.js` (6) — resolves null for `none`/`custom`,
correct adapter for `umami`/`rybbit`, back-compat path via
legacy `analytics_umami_enabled`, garbage-provider defensive null.
- `customScriptSanitiser.test.js` (12) — Plausible-style passthrough,
Umami-style passthrough, inline body passthrough, `<noscript>`
allowed, `<link rel="preconnect|dns-prefetch">` allowed,
`<link rel="stylesheet">` stripped, disallowed tags stripped,
`javascript:`/`data:` URLs stripped, `on*` event handlers
stripped, defensive on malformed input.
- `analyticsDateMerge.test.js` (5) — preserved from #662.
**Frontend**: full 84-case vitest suite green; tsc + eslint clean
on changed files. Adapter changes are narrow refactors of code
covered by backend tests; no new analytics-page unit test added.
## End-to-end smoke (dockerised backend + my changes mounted)
```
test 1 (back-compat: no provider, umami_enabled=true)
→ factory returns umami adapter, /analytics returns
devicesSource:access_logs (umami fetch to fake host fails
gracefully). ✓
test 2 (invalid provider value)
→ 400 "analytics_tracker_provider must be one of: none, umami,
rybbit, custom" ✓
test 3 (save custom HTML with XSS payload)
→ stored sanitised:
`<script>alert(1)</script>evil<script async defer
data-domain="x.com" src="https://plausible.io/js/script.js"></script>`
(<div> stripped; script tags survive but CSP `script-src 'self'`
still blocks inline + non-allowlisted external at runtime) ✓
test 4 (public-settings exposes the provider switch)
→ `analytics_tracker_provider: 'custom'`,
`analytics_custom_head_html: '<sanitised>'` ✓
```
## Out of scope (next discussions)
- **Plausible native** — covered via Custom mode for now; native is
Phase 2 if someone explicitly asks.
- **CSP "trusted domains" admin input** — Phase 1.5. For now operators
add their tracker domain to nginx/proxy CSP manually; the new
CSP-reminder banner in the Custom panel makes that clear.
- **Refactor `(window as any).umami.track(...)` direct calls** in
PhotoLightbox/PhotoGrid to go through `analyticsService.track()`
so events fire on the right tracker. Currently a no-op when Umami
isn't loaded; functional but not optimal.
Closes#663 Phase 1.
The last-resort fallback hardcoded 'wedding', which breaks when the admin has
disabled that type. resolveDefaultEventType now prefers the generic 'other'
catch-all when active, else the first active type by display order, and only
uses a literal as a final guard if the catalog is empty/unreadable. The chosen
quote type and the crm_default_event_type setting still take precedence.