Compare commits

..

674 Commits

Author SHA1 Message Date
Paul Nothaft 1d48f59fe1 chore(stable): release 3.45.2 (#819)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 07:34:35 +00:00
Paul Nothaft e37d1fac58 Merge pull request #818 from PicPeak/fix/legacy-events-router-bola-stable
fix(security): remove unguarded legacy /api/events router on stable (GHSA-4j34-x562-5vfq)
2026-07-17 09:29:24 +02:00
Paul Nothaft 9ee3ff45d0 fix(security): remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq)
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
  - GET /api/events → every gallery's bcrypt password_hash, share_token, and
    client name/email (the list handler selects * and mapEventForApi keeps
    those columns),
  - PUT /api/events/:id → reset any gallery's password (full takeover),
  - DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.

Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).

Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
2026-07-17 09:18:28 +02:00
Paul Nothaft 5453152f1c chore(stable): release 3.45.1 (#815)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-16 11:44:00 +00:00
Paul Nothaft b416baec5c Merge pull request #812 from PicPeak/fix/security-advisories-backend-stable
fix(security): close 4 open security advisories on stable (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal)
2026-07-16 13:37:43 +02:00
Paul Nothaft 38ddd70c12 Merge pull request #809 from PicPeak/fix/docker-image-os-cves-stable
chore(security): close 21 frontend image CVEs on stable — nginx 1.30 base + apk cache-bust
2026-07-16 13:37:40 +02:00
Paul Nothaft b00a16159e fix(security): harden .picpeak restore operator-preservation (GHSA-qxfx follow-up)
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):

- MFA hijack: reinject wrote back only password_hash/is_active/
  must_change_password, leaving a crafted backup's two_factor_* on the
  operator's row — it could strip or replace their second factor. The email-
  matched row is now updated with the operator's full AUTH set (login identity,
  password, and all two_factor_* columns). Relationship/audit FKs (role_id,
  created_by) are deliberately NOT forced from the snapshot: on a cross-instance
  restore those pre-restore ids may be absent from the backup and would dangle
  the FK (SQLite rolls back at commit); the restored row keeps its own valid
  values.

- Cross-instance restore rollback / FK safety: reinject matched only by email,
  so a backup shipping a different admin with the default `admin` username hit
  UNIQUE(username) and rolled the whole restore back; email and username could
  even collide on two different rows. Reconciliation is now non-destructive:
  the email-matching row is updated in place (id preserved → restored FKs like
  events.created_by stay valid); any different row holding the operator's
  username is RENAMED, not deleted (deletion would fire ON DELETE actions /
  dangle references); only when no row has the operator's email is a fresh row
  inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
  left the Postgres identity sequence unadvanced, so a sequence-based insert
  could collide).

- Stale session after restore: admin_users ids shift on restore, but the
  operator's live JWT is bound only to decoded.id (IP logged not enforced; the
  backup controls password_changed_at). The route now revokes the token (result
  checked and logged) and clears the admin cookie; the client redirects to a
  fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
  guarantee.

Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.

Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
2026-07-16 12:31:28 +02:00
Paul Nothaft dcfcb67f9b fix(security): sanitize chunked-upload filename (GHSA-pc72-jf53-w28j)
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.

Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
2026-07-16 10:56:15 +02:00
Paul Nothaft cde0b465a9 fix(security): reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x)
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.

Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
2026-07-16 10:56:15 +02:00
Paul Nothaft 28f69e4bf3 fix(security): share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw)
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.

Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.

Adds route regression test covering the bypass, the public path, and bad tokens.
2026-07-16 10:56:15 +02:00
Paul Nothaft 1cf82d81a7 fix(security): preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f)
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.

Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
2026-07-16 10:56:15 +02:00
Paul Nothaft ae98e7ad74 chore(security): close 21 frontend image CVEs — nginx 1.30 base + apk cache-bust
The frontend image kept shipping vulnerable OS packages (nginx 1.28.3-r1,
curl/libcurl 8.19.0, c-ares 1.34.6) despite the apk upgrade line, for two
independent reasons:

1. The runtime stage's apk upgrade layer was cached indefinitely — the
   CACHEBUST build-arg CI passes (github.run_number) was only declared in
   the builder stage, and ARGs don't cross stage boundaries. Both
   Dockerfiles now redeclare CACHEBUST in the runtime stage and consume it
   in the apk RUN, so every build re-runs the upgrade and picks up current
   Alpine security updates.

2. nginx itself can never upgrade via apk on the nginx.org-based image:
   the bundled nginx-module-* packages pin the exact nginx version, so
   Alpine's patched 1.28.3-r4 is unreachable (verified empirically —
   apk add --upgrade nginx is a silent no-op). nginx fixes must come via
   the base tag, so bump to nginx:1.30-alpine (current stable, 1.30.4 on
   Alpine 3.24, same nginx.org conf.d layout — drop-in).

Verified: local image build scans clean with Trivy (0 OS findings, was 21);
container serves /health, SPA fallback, and BRAND_TITLE envsubst as non-root
nginx user.

Closes code-scanning alerts 371-374, 376-392 (nginx HTTP/2 & module CVEs,
curl CVE-2026-5773/-6276 + 6 medium, c-ares CVE-2026-33630).
2026-07-16 10:30:42 +02:00
Paul Nothaft caa9fe5d56 chore(stable): release 3.45.0 (#777)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-09 11:22:46 +00:00
Paul Nothaft c6e61f64ba Merge pull request #775 from PicPeak/ci/release-please-target-stable-on-stable
ci(release): cut the real v3.45.0 stable (target-branch: stable)
2026-07-09 13:13:38 +02:00
Paul Nothaft 3ec0451cbb ci(release): pin target-branch: stable so release-please cuts the real v3.45.0
Triggers the correct stable release from the stable branch (manifest
3.44.0 -> 3.45.0). Same fix as #774 (which fixes it on main for future
promotes); merging this to stable is what re-runs release-please
correctly for the promote that mis-fired as v2.7.0.
2026-07-09 11:40:44 +02:00
Paul Nothaft edac463ec3 Merge pull request #771 from PicPeak/release/3.83.0-merge-from-beta
chore(release): promote beta → stable (v3.83.0 line)
2026-07-08 20:42:43 +02:00
Paul Nothaft 2d3537f61c ci: run the Tests workflow on stable-targeted PRs (unblock this promote)
Same one-liner as #772 — adds stable to tests.yml push/pull_request
filters so the required backend/frontend checks report on this PR
instead of hanging on 'Expected — Waiting for status to be reported'.
2026-07-08 20:29:41 +02:00
Paul Nothaft 6025b3194d chore(release): align README/DEPLOYMENT_GUIDE with main (promote content) 2026-07-08 20:01:48 +02:00
Paul Nothaft 8713ab7f60 chore(release): keep stable manifest (3.44.0) + CHANGELOG for release-please-stable 2026-07-08 20:00:13 +02:00
Paul Nothaft 8994901e4a chore(release): promote beta → stable (v3.83.0 line)
Merge main (v3.83.0-beta.0) into stable to cut the next stable release.
Conflicts resolved toward main (the promoted code); stable release-control
files (manifest, CHANGELOG) restored separately.
2026-07-08 19:59:55 +02:00
Paul Nothaft c29ad747f2 chore(main): release 3.83.0-beta.0 (#770)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-08 10:42:24 +00:00
Paul Nothaft a71b9b5ed7 Merge pull request #769 from Luca-Timo/feat/messages-email-client
feat(messages): unified Messages email client (flag-gated, default off)
2026-07-08 12:38:58 +02:00
Luca 1e08a4fb15 fix(messages): PR #769 nits — server-side search, bare-email recipient, DE i18n
- Search now hits the backend (debounced) so results aren't truncated to the
  first loaded page: /received gains a `q` filter (sender/subject); the frontend
  passes the debounced term to every list query. The instant client-side filter
  stays for responsiveness.
- Reply/compose recipient extracts the bare address from a "Name <addr>" From
  header (extractEmail) — also used for the customer-lookup key.
- Added the full de + en `messages.*` and `email.customerMailbox.*` translation
  namespaces (were English inline-fallbacks only). Swiss-German spelling.
2026-07-07 18:39:04 +02:00
Luca bb235e72e5 fix(messages): PR #769 review — escape reply sender (XSS), gate backend routes, exact customer match
- BLOCKER: stored XSS via inbound sender display name. The reply stub built raw
  HTML with the unsanitized From name and set it as innerHTML on the composer's
  contentEditable (admin origin) → onerror JS ran on Reply. Now HTML-escape
  from_address in the stub AND DOMPurify-sanitize the composer body before
  innerHTML (defense in depth).
- Gate the NEW Messages routes with requireFeatureFlag('messaging') per-route
  (queue/:id, received/:id, item/*, identities, accounts, accounts/test, send)
  — NOT the shared /email mount, so the pre-existing email-config endpoints stay
  ungated.
- DocumentActionModal auto-picks a customer only on an EXACT email match
  (customer search is prefix/fuzzy), else leaves the picker to the admin.
2026-07-07 18:28:26 +02:00
Luca 99d5996561 feat(messages): search bar + Archive/Delete with Archived & Deleted folders
- Search box in the header filters the current folder's list (sender/subject),
  client-side; works across the merged Archived/Deleted views too.
- Archive and Delete are now implemented as soft moves: migration 157 adds
  mailbox_state ('active'|'archived'|'deleted') to email_queue + received_emails.
  Archive → 'archived', Delete → 'deleted' (trash). Restore → 'active'. Deleting
  FROM the Deleted folder is permanent (hard row delete).
- New cross-account system folders Archived + Deleted (merge sent + received of
  that state, sorted by date). Normal folders now exclude archived/deleted.
- Backend: /queue + /received gain a `state` filter (default active + legacy
  NULL); new POST /item/:kind/:id/state (archive/delete/restore) and DELETE
  /item/:kind/:id (purge, email.edit).
- Toolbar Archive/Delete wired; Restore + "Delete permanently" shown in the
  system folders.

Frontend build + migration boot (157) verified.
2026-07-07 16:16:51 +02:00
Luca c8cb4c88ca harden(messages): SSRF guard on mailbox host, strict sandbox + sanitizer, per-account TLS
Pre-upstream review hardening:
- /accounts + /accounts/test now reject private/internal IMAP/SMTP hosts via
  isPrivateIP(), matching /config + /incoming-config (SSRF).
- QueueDetail body iframe uses sandbox="" (script-less, no same-origin) like the
  inbound pane, instead of allow-same-origin.
- /send sanitizer drops the <style> tag + data: scheme to match the stricter
  inbound sanitizeBody allowlist.
- Per-account SMTP transport sets tls.rejectUnauthorized explicitly.
2026-07-07 16:04:11 +02:00
Luca 2c5c1d561b fix(messages): show the resolved customer's name in the doc-action modal
CustomerPicker uses its 'label' prop as the selected-customer chip text, so
passing the static 'Customer' string hid the actual name. Pass the resolved
customer's name as label and add a separate field heading.
2026-07-07 15:45:59 +02:00
Luca 0dbf863f60 feat(messages): create/select quote, contract, invoice, gallery from a message
The toolbar doc buttons now open a real document-action flow instead of just
loading an email template:

- DocumentActionModal resolves the customer from the message's sender address
  (customers/search); if no match, the CustomerPicker lets you search or create
  a passive customer inline.
- Create new -> jumps to the real editor prefilled with the customer
  (quotes/contracts/bills ?customerAccountId=), so numbering, line items and PDF
  all come from the existing CRM. Gallery opens the event editor.
- Select existing -> lists that customer's quotes/contracts/invoices and drops
  the chosen document number into a reply composer.
- Toolbar buttons are gated by the global feature flags (quotes/contracts/bills).

Adds the missing customerAccountId prefill to ContractEditorPage (quotes + bills
already had it). Frontend-only; reuses existing endpoints. Build verified.
2026-07-07 13:16:18 +02:00
Paul Nothaft 72cbb95b44 chore(main): release 3.82.6-beta.0 (#768)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-07 10:35:31 +00:00
Luca c622a35033 Merge pull request #358 from Luca-Timo/feat/messages-email-client
fix(messages): show only the mailbox local part in the sidebar (full …
2026-07-07 12:33:18 +02:00
Luca 88fe9f9844 fix(messages): show only the mailbox local part in the sidebar (full address on hover) 2026-07-07 12:32:08 +02:00
Luca 9596342d6a Merge pull request #764 from Luca-Timo/fix/dunning-backfill-on-enable
fix(workflows): backfill existing invoices + anchor dunning grace to due date when enabled (#750)
2026-07-07 12:31:15 +02:00
Luca 7cc1c59661 Merge pull request #357 from Luca-Timo/feat/messages-email-client
fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP
2026-07-07 12:08:59 +02:00
Luca f9c2b4ed75 fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP
Addresses dev-test feedback:
- Sidebar + reading-pane addresses (rechnungen@ / hello@ / no-reply@) are now
  read from the mail config via GET /admin/email/identities, not hardcoded.
- Highlight/selection now uses the branding accent (bg-accent-soft /
  text-on-accent-soft / accent-dark) instead of hardcoded blue, so it follows
  the admin's CI colour like the sidebar.
- Header gains "New message" (compose) and "Sync" (poll mailboxes now) buttons.
- Composer modal enlarged (920px, taller editable body).
- Customer mailbox (hello@) now has BOTH incoming (IMAP) and outgoing (SMTP)
  settings — migration 156 adds smtp_* + from_* to mail_accounts;
  emailProcessor.sendRawEmail takes an accountKey and sends via that mailbox's
  SMTP identity (falls back to the global from). Manual/reply sends from the
  Messages UI use the 'customers' identity, so replies come from hello@.

Frontend build + migration boot (156) verified.
2026-07-07 11:58:41 +02:00
Luca 976d52280e Merge pull request #356 from Luca-Timo/feat/messages-email-client
Feat/messages email client
2026-07-07 11:11:21 +02:00
Luca 0ed3bbefa1 chore(messages): describe the feature as 'unified' rather than by a third-party product name 2026-07-07 11:10:00 +02:00
Luca b96ad36f5d fix(messages): make the Messaging feature flag toggleable
The Messaging FeatureCard was a hardcoded-disabled 'roadmap' placeholder
(no-op toggle), so the messaging flag could never be turned on — the Messages
sidebar item + page stayed hidden. Wire the toggle to setFlag, mark it 'new',
and describe the actual admin Messages client.
2026-07-07 11:06:16 +02:00
Luca d0bdcb1a6a Merge pull request #355 from Luca-Timo/feat/messages-email-client
feat(messages): Outlook-style Messages email client (3 phases, flag-gated)
2026-07-07 10:48:46 +02:00
Luca 768e84711f feat(messages): Phase 3 — editable-template composer, reply + create actions
The CRM action buttons and Reply now open a send-composer, not a silent
templated send.

- New send-composer (MessageComposer): loads the rendered template (via
  previewTemplate) or a reply stub into a fully-editable body — the admin can
  rewrite it or drop a note anywhere before sending. On send it goes out as-is
  (server-sanitized), no template re-render.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
  configured SMTP identity; POST /admin/email/send sanitizes + sends + records
  the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
  stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
  the human/edited messages (which finally populates that folder). /queue gains
  an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
  Create Quote/Contract/Invoice open the composer with that template loaded;
  Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
  (later phases). After send, jumps to Customers ▸ Sent.

Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
2026-07-07 10:42:54 +02:00
Luca da3a77dac4 fix(workflows): scope dunning backfill to its own flow via targetWorkflowId
Address review on #764: backfillDunningRuns emitted invoice.sent without a
target, so enabling dunning would also enroll every historical open invoice
into any custom invoice.sent flow. Pass the enabled flow's id through to
emitWorkflowEvent so the backfill only touches dunning. Also note the
computeWakeAt both-fields (untilVar + delay) behaviour change in its comment.
2026-07-07 10:33:01 +02:00
Luca ee46cf2125 feat(messages): Phase 2 — customer (hello@) mailbox + inbound body capture
Second inbound mailbox and real message bodies for the Messages viewer.

Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
  primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
  body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
  mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
  accounting keeps its exact attachment->expenses behavior, customer mail is
  logged with its body and NOT routed to accounting. Inbound HTML is sanitized
  server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
  (bodies excluded from the list); new GET /received/:id returns the body;
  GET/POST /accounts + /accounts/test manage the extra mailboxes.

Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
  sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
  Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
  configure + test the hello@ IMAP box.

No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
2026-07-07 10:11:58 +02:00
Paul Nothaft c0ac5c36a4 chore(main): release 3.82.5-beta.0 (#767)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-07 07:08:02 +00:00
Paul Nothaft 760a201b60 Merge pull request #766 from PicPeak/fix/date-input-invalid-crash
fix(admin): stop the event-date field crashing the page on backspace
2026-07-07 09:04:04 +02:00
Paul Nothaft 945026d446 fix(admin): stop the event-date field crashing the page on backspace
Repro: create an event, click into the date field, backspace a day digit.
The whole page white-screened and needed a reload.

Root cause: LocalizedDateInput's `toIso` only checked the day/month were
1-2 digits, not that they formed a real date — so a mid-backspace value
like "0/07/2026" was coerced to the string "2026-07-00" and committed to
`event_date`. CreateEventPage then rendered
`format(addDays(new Date('2026-07-00'), days))`, and date-fns `format`
throws RangeError on an Invalid Date — thrown during render, so React
tore the tree down to the error boundary.

Two complementary fixes:
- `toIso` round-trips the parsed y/m/d through `Date` and rejects
  impossible dates (day 00, month 13, 31 Feb…), so the field never
  commits a value that isn't a real calendar date.
- `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid`
  and return '' instead of throwing — defence in depth for the ~57 call
  sites that could otherwise white-screen on a bad date.

Verified live: backspacing to a partial/invalid date no longer crashes
(the form stays rendered), a valid date still commits + the expiry
preview renders. Adds a LocalizedDateInput regression test; tsc + build
green.
2026-07-07 08:56:04 +02:00
Paul Nothaft 2522d7e1ce chore(main): release 3.82.4-beta.0 (#765)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-07 05:13:53 +00:00
Paul Nothaft 0c2d319fc1 Merge pull request #763 from Luca-Timo/fix/email-language-and-payment-confirm
fix(email,ui): billing emails follow customer language + readable payment-check confirmation
2026-07-07 07:10:09 +02:00
Luca 26eeb76197 feat(messages): Phase 1 read-only Messages viewer (email client shell)
New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):

- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
  / Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
  received_emails (listReceived). Customers folders show an explanatory empty
  state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
  sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
  attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
  accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
  Book-as-expense-Re-bill) present but disabled — wired in later phases.

Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
2026-07-07 03:15:41 +02:00
Luca c0008be39b fix(email): sibling billing emails follow customer language too
Addresses the PR #763 review: the invoice_sent / storno_issued / payment-check
/ paid-admin-notification emails share the identical event-first language bug
and never set __language, so a German customer on an English-gallery event got
an English email body with German-formatted amounts.

Each call site already computes the locale it formats amounts in, so this is a
one-liner per call — the body language now matches the amount formatting:
- invoice_sent, storno_issued (sending.js) -> __language: ctx.locale
- payment-check, invoice_paid_admin_notification (payments.js) -> __language: locale

Leak-safe (no template references {{__language}}) and falls back to the
existing event-first resolution when unset, per the mechanism added in #763.
2026-07-06 22:10:18 +02:00
Luca 2c7b351458 fix(workflows): backfill existing invoices + anchor grace to due date when dunning is enabled (#750)
Enabling the invoice-dunning built-in suppressed the legacy reminder ladder
but only created runs for invoices sent AFTER enabling — already-sent unpaid
invoices got dunned by neither. Now:

- Turning dunning ON enrolls every open sent/overdue unpaid invoice via
  emitWorkflowEvent('invoice.sent') (engine.backfillDunningRuns(), wired into
  the workflow enable toggle). Idempotent via the per-(flow,entity) dedup.
- The grace wait is anchored to the invoice's due date: computeWakeAt now
  treats { untilVar, delayDays } as "var + offset" (was var-only OR now+offset),
  and the built-in's waitGrace becomes { untilVar: 'dueDate', delayDays:
  firstDays } (seed v6 -> v7). An already-overdue invoice duns on its real
  timeline instead of restarting a fresh grace clock.

Note: the due-date-anchored graph applies to freshly seeded built-ins; an
already-admin-enabled dunning workflow still enrolls via backfill but keeps its
current grace timing until re-seeded.
2026-07-06 21:57:23 +02:00
Luca ea86871b81 Merge pull request #354 from Luca-Timo/fix/email-language-and-payment-confirm
fix(email,ui): billing emails follow customer language + readable pay…
2026-07-06 19:33:47 +02:00
Luca fcc3e9195d fix(email,ui): billing emails follow customer language + readable payment-check confirmation
- Billing/dunning emails no longer render in the gallery event's language.
  emailProcessor now honors an explicit `__language` in the email data
  (else falls back to the event-first recipient resolution), and the invoice
  reminder passes the customer/invoice locale (customer.preferred_language
  || invoice.language || 'de'). Fixes German customers getting English
  dunning notices. (#760)
- Payment-check confirmation card ("Action recorded") is now theme-adaptive
  (green tint + readable text on both light and dark surfaces) instead of a
  hardcoded light-green mix + dark-green title that vanished in dark mode. (#759)
- The per-customer "Preferred language" field already exists
  (CustomerDetailPage) plus the business-profile default; updated the helper
  text to note billing emails now honor it too. (#761)
2026-07-06 19:27:57 +02:00
Paul Nothaft a52317d8a5 chore(main): release 3.82.3-beta.0 (#758)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-06 09:53:29 +00:00
Paul Nothaft a88da99c8d Merge pull request #757 from PicPeak/fix/hero-logo-global-inherit-756
fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
2026-07-06 11:50:06 +02:00
Paul Nothaft 60b03b1728 fix(branding): unify hero logo SIZE the same way as visibility (#756)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.

Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.

- Migration 153: hero_logo_size nullable + backfill NULL so existing
  galleries inherit the global size (restores GalleryLayout's prior
  live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
  per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
  heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.

Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
2026-07-06 11:44:33 +02:00
Paul Nothaft 96fe478bf8 fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).

Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.

- Migration 152: make events.hero_logo_visible nullable and NULL out the
  defaulted  rows so existing galleries follow the global going
  forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
  path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
  default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
  (GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
  Always show / Always hide (en + de).

Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
2026-07-06 11:20:36 +02:00
Paul Nothaft 43213b50ce chore(main): release 3.82.2-beta.0 (#755)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-05 21:43:21 +00:00
Paul Nothaft a87ad77d8d Merge pull request #754 from PicPeak/fix/og-shorturl-slideshow-viber-699
fix(og): route branded short URLs + slideshow to OG handler, add Viber (#699)
2026-07-05 23:39:59 +02:00
Paul Nothaft a0a28a4777 fix(og): broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.)
From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens
to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync):
Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal,
Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap,
Google-PageRenderer, OdklBot, ZoomBot.

Deliberately NOT added: UAs shared with real human in-app browsers
(WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings
('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG
response is meta-only with no redirect, so matching those would serve a
human the bare stub. New negative test locks that exclusion in.

Verified: nginx -t passes; live harness confirms the new tokens rewrite
to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15.
2026-07-05 23:37:16 +02:00
Paul Nothaft 0dffe0ce92 fix(og): route branded short URLs + slideshow links to OG, add Viber (#699)
Follow-up to #699/#700/#702 — the OG SSR handler existed but three link
shapes never reached it behind the frontend nginx:

- Branded short URLs (/s/<slug>, #702) had NO nginx location, so they fell
  through to the SPA — which has no /s/ route. Dead for humans (no 302
  redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose
  /s/:shortSlug route already handles both.
- Slideshow links (/gallery/<slug>/show/<token>) have TWO extra path
  segments; the crawler-detect location regex allowed only one, so they
  never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra
  segments (quoted regex — the braces would otherwise be parsed as nginx
  config delimiters). client-access still matches (its token is in ?query,
  one path segment).
- Viber's preview fetcher wasn't in either UA list, so Viber shares showed
  no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync).

Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness
confirms /s/ proxies to the backend, slideshow + Viber + share-token +
client-access crawler UAs all rewrite to /og/gallery/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
2026-07-05 21:19:53 +02:00
Paul Nothaft f15e104702 chore(main): release 3.82.1-beta.0 (#753)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-05 17:27:41 +00:00
Luca 9a763337b6 Merge pull request #752 from Luca-Timo/fix/dunning-payment-check-template-key
fix(invoices): correct payment-check email template key so dunning email sends
2026-07-05 19:24:37 +02:00
Luca 3682de195b fix(invoices): correct payment-check email template key so dunning email sends
queuePaymentCheckEmail queued the admin payment-check email with template
key 'invoice_payment_check_admin', but no such template exists — the only
one is 'invoice_payment_check' (crmEmailTemplates.js:217, seeded by
migration 116), which IS the admin "Paid / Partial / Not paid" email. The
processor does an exact template_key lookup and throws "template not
found", so every dunning admin payment-check email failed, retried to the
cap, and got stuck pending.

One-word fix: queue 'invoice_payment_check'. Unbreaks the built-in
invoice-dunning flow's email step. (Rebased onto the post-decompose
invoiceService refactor — the line now lives in invoice/payments.js.)
2026-07-04 23:51:22 +02:00
Paul Nothaft c3ed7f1693 chore(main): release 3.82.0-beta.0 (#742)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-03 11:10:31 +00:00
Paul Nothaft a5f49e3235 Merge pull request #741 from PicPeak/feat/setup-final-step-and-button-fix
feat(setup): final community step (#732) + fix create-admin button overflow (#730)
2026-07-03 13:07:21 +02:00
Paul Nothaft dadaaeea77 feat(setup): final community/thank-you step (#732); fix create-admin button overflow (#730)
#730 — the account step's Create-admin button shared a flex row with Back;
its label + loading spinner exceeded the card width, so the button
overflowed the card outline while submitting (and was fragile for longer
i18n labels). Stack both buttons full-width — the primary always has room
for the spinner now, matching every other wizard step.

#732 — add a final 'community' step, shown once on first-run after
config / no-config, before entering the app. Mission line + four link
cards (report a bug, request a feature, star/share, Buy Me a Coffee),
all target=_blank rel=noopener, and a Finish → Dashboard button. Fully
i18n (en + de). Restore keeps its reload flow (a restored instance is no
longer first-run, so it never reaches this step). Adds .github/FUNDING.yml
so GitHub renders a Sponsor button too.

Verified live: drove the real first-run wizard end to end — stacked
account buttons render inside the card, community step shows the mission
+ all four links, Finish lands on the dashboard.
2026-07-03 12:29:58 +02:00
Paul Nothaft c76877c0c4 chore(main): release 3.81.0-beta.0 (#740)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-03 10:08:29 +00:00
Paul Nothaft e985c6bacd Merge pull request #733 from PicPeak/refactor/codebase-cleanup
refactor: codebase-wide cleanup — dead code, dedup, standardization, god-file decomposition
2026-07-03 12:05:33 +02:00
Paul Nothaft f564b38c5a Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup
# Conflicts:
#	backend/src/routes/adminEvents.js
#	backend/src/routes/protectedImages.js
#	frontend/src/pages/admin/EventDetailsPage.tsx
2026-07-03 11:54:01 +02:00
Paul Nothaft cf073615ef Merge pull request #739 from PicPeak/feat/admin-mfa
feat: admin two-factor authentication (TOTP) with recovery codes + CLI reset
2026-07-03 11:49:07 +02:00
Paul Nothaft b732974779 Merge pull request #737 from PicPeak/fix/auth-access-control
fix(security): cross-event thumbnail leak, bulk-op ownership bypass + auth hardening
2026-07-03 11:47:25 +02:00
Paul Nothaft b187f588b4 Merge pull request #734 from PicPeak/fix/event-create-nan-and-bool-render
fix: event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders
2026-07-03 11:46:58 +02:00
Paul Nothaft 96e3c68b9d feat(admin-ui): TOTP MFA enrollment + two-step login; remove stub 2FA toggle
Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
  per-user setup (QR + manual secret + verify), recovery codes shown once
  (copy/download/confirm), status, regenerate, disable. Renders for
  super_admin (closes #735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
  code step (TOTP or recovery), call /auth/admin/login/mfa; handle
  MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
  (and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.

Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
2026-07-03 11:44:07 +02:00
Paul Nothaft cdbfb514bd test(auth): MFA unit + route + CLI coverage (39 tests)
mfaService unit (encrypt/decrypt, TOTP, single-use recovery, isEnrolled),
adminMfa HTTP (enroll/challenge/verify/recovery/disable; super_admin
enrollment guards #735), and reset-admin-mfa.js CLI.
2026-07-03 11:37:51 +02:00
Paul Nothaft 72e2ef6721 feat(auth): admin TOTP MFA — enrollment, login challenge, recovery, CLI reset
Backend for #738. Real TOTP 2FA for admin accounts, all roles incl.
super_admin (closes #735).

- mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest
  (key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed,
  single-use recovery codes; otpauth URI + QR.
- Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at
  (secret/enabled columns already existed from legacy 016).
- Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status,
  POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate
  require a current code so a hijacked session can't strip 2FA.
- Login challenge: /admin/login returns {mfaRequired, mfaToken} (no
  session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery
  code for the session. Lockout counter is NOT reset until the second
  factor passes, so MFA brute-force is rate-limited too.
- CLI break-glass: scripts/reset-admin-mfa.js --email <e> | --all --yes,
  audit-logged, matches reset-admin-password.js convention.
- Docs + optional MFA_ENCRYPTION_KEY env.

Verified end-to-end on a live backend: enroll (super_admin), challenge,
TOTP + single-use recovery login, disable, and CLI reset.
2026-07-03 11:33:38 +02:00
Paul Nothaft 081f3edcdf fix(security): close cross-event thumbnail leak, bulk-op ownership bypass, + hardening
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.

HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
  flat /thumbnails/thumb_* file, so a visitor to one gallery could
  enumerate another (password-protected) gallery's entire thumbnail set.
  Scope thumbnail access to the token's event via photos.thumbnail_path.
  Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
  event ids with no owner filter (single-event routes enforce
  requireEventOwnership), letting admin/editor archive or cascade-delete
  any event. Add filterOwnedEventIds; also guard rename + import-external;
  tighten photo-retry to scope admin (not just editor). Fix misleading
  bulk-delete comment.

MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
  instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
  (#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
  to absolute/external URLs — only attach to relative same-app paths.

LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.

Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
2026-07-03 10:27:28 +02:00
Paul Nothaft 5b26dbd935 chore(main): release 3.80.0-beta.0 (#736)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-03 08:05:34 +00:00
Paul Nothaft e513e8345b Merge pull request #731 from Luca-Timo/feat/setup-wizard-and-backup-roundtrip
feat: first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip
2026-07-03 10:03:22 +02:00
Paul Nothaft 766351b588 fix: mirror #734 onto decomposed files (PG NaN slideshow seed, SQLite bool renders)
Same two pre-existing-on-main bugs, at their post-decomposition
locations: clampIntOrUndefined in adminEvents/crud.js slideshow seed;
!! coercion in EventDetailsHeader, EventInformationCard,
ClientAccessCard. Keeps this branch correct in either merge order with
#734 — when merging main afterwards, resolve the adminEvents.js
modify/delete conflict by keeping the deletion.
2026-07-03 09:01:01 +02:00
Paul Nothaft 760c3d7b67 fix(admin): stray literal "0" rendered from SQLite integer booleans
On SQLite deployments boolean event columns come back as 0/1, and
{event.is_draft && ...} renders the 0 as a literal text node. Visible on
the event details page in three spots: above the tab bar (is_draft),
in the download-protection badge row (disable_right_click /
enable_devtools_protection / watermark_downloads), and in the Client
Access card (client_access_enabled). Coerce with !! at the render sites.
2026-07-03 08:57:48 +02:00
Paul Nothaft 8c86518aad fix(events): NaN from slideshow seed breaks event creation on PostgreSQL
The create route seeds show_interval_ms/show_transition_ms from
app_settings through an inline guard that pre-checked Number.isFinite(+v)
but then used parseInt(v). The two disagree for null/''/true — +null is 0
(finite) while parseInt(null) is NaN — so when the slideshow settings rows
are absent (getAppSetting returns its null default), NaN flowed through
Math.min/Math.max into the INSERT. PostgreSQL rejects NaN for integer
columns; SQLite silently stores NULL, which is why every SQLite-based
test passed while POST /api/admin/events 500'd on the PG dev stack and
broke the e2e smoke suite.

Fix: parse first, then check — clampIntOrUndefined in utils/numericHelpers
(unit-tested against every failure-mode input). Verified end-to-end: the
previously-failing minimal create now succeeds against the PG dev stack.
2026-07-03 08:57:48 +02:00
Paul Nothaft 2ea26a4962 fix: conform moved code to eslint indent/quotes, 4-arg mutation callbacks
- eslint --fix on branch-changed backend files (indent shift from the
  module-wrapper nesting in decomposed files); backend lint now 904
  errors vs 1,315 on main
- useMutationWithToast forwards all four TanStack v5 callback args
  (tsc -b strict build flagged the 3-arg passthrough)
2026-07-03 08:17:14 +02:00
Paul Nothaft b5eafc52bd refactor(frontend): decompose EventDetailsPage and ThemeCustomizerEnhanced
Move-code split, entry paths/exports unchanged:
- EventDetailsPage.tsx (2,697 -> 679) + pages/admin/event-details/* (16 files)
- ThemeCustomizerEnhanced.tsx (1,541 -> 349) + admin/theme-customizer/* (11)
Known ephemeral-UI delta: widget-local state (copied-link flags, unsaved
PIN input, modal selection) now resets when a tab unmounts.
2026-07-03 08:10:25 +02:00
Paul Nothaft fdf62dafef refactor(backend): decompose invoiceService, contractService, adminEvents
Move-code split, public entry points unchanged:
- services/invoiceService.js (3,623 -> 99) + services/invoice/* (10 modules)
- services/contractService.js (2,363 -> 83) + services/contract/* (6 modules)
- routes/adminEvents.js -> routes/adminEvents/* (crud, slideshow, resets,
  archive/bulk, logo); route registration order verified identical
Lazy cross-service requires preserved to keep the module graph acyclic.
2026-07-03 08:10:25 +02:00
Paul Nothaft 51f827774a refactor(frontend): extract shared PhotoCard from gallery layouts
One card implementation (hover overlay, download/expand/select, likes,
identity flow, lazy render) replaces per-layout copies in Masonry,
Justified, Grid, Mosaic, Timeline (-1270/+395 in layouts). Carousel,
Premium, Story stay bespoke — different DOM/design by intent.
2026-07-03 07:49:45 +02:00
Paul Nothaft 0f230f53fb refactor(frontend): useMutationWithToast + useModal hooks, migrate admin surfaces
- 92 mutations across 40 files moved to useMutationWithToast
  (success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
  to avoid introducing new error toasts
2026-07-03 07:49:45 +02:00
Paul Nothaft 6eb46d8c31 refactor(backend): standardize error responses, logging, pagination
- errorResponse(res, error, status, publicMessage) in routeHelpers,
  wired into 125 catch blocks across 10 route files; wire format
  ({ error: <string> }) unchanged byte-for-byte
- Replace remaining console.* with logger across src (178 sites);
  3 intentional console sites kept (install boot, unbound .catch ref)
- Adopt getPagination in 6 routes where semantics match exactly
2026-07-03 07:49:45 +02:00
Paul Nothaft d44ead41c5 test: add smoke tests for invoiceService, adminEvents routes, backupService
26 tests as a safety net ahead of decomposition — invoice create/list/
status transitions, adminEvents CRUD via Supertest+SQLite, backup config
parsing and manifest validation.
2026-07-03 07:49:45 +02:00
Paul Nothaft 9d7b6f0e11 test: extend documentSequences mock for shared nextDocumentNumber 2026-07-03 07:27:43 +02:00
Paul Nothaft eb71fcf209 refactor: remove dead files, dedupe formatBytes and document numbering helpers
- Delete unused adminEvents-enhanced.js, backupService.original.js,
  databaseBackup.example.js, s3Storage.example.js, ThemeCustomizer.tsx
- Extract shared formatBytes to utils/formatBytes.js (was copied 4x)
- Centralize formatNumberInTemplate + next-document-number logic in
  utils/documentSequences.js (was copied in invoice/quote/contract services)
2026-07-03 07:25:07 +02:00
Luca fa7665c5b1 fix(backup): address .picpeak review — table filter, superuser guard, tests
From the-luap's review:

- Import no longer trusts manifest.tables blindly. It now intersects the
  manifest's table list with the real data tables of THIS database
  (listDataTables(), which already excludes knex_migrations/_lock) and
  drops anything else. A crafted/corrupted .picpeak listing knex_migrations
  or a non-existent table can no longer wipe it; skipped tables are logged.
- The Postgres session_replication_role='replica' SET (needs superuser) is
  now wrapped: on a managed-PG non-superuser it fails BEFORE any rows are
  deleted (transaction rolls back) and surfaces a clear, actionable 400
  instead of a cryptic permission error.
- Export: on an archiver error, the temp out dir (a partial plaintext-secret
  archive) is now removed instead of orphaned.

Tests (+4, now 26): engine-mismatch rejection, forward-only newer-refused,
non-picpeak rejection, and files/ restored + filesRestored asserted.
2026-07-03 01:39:13 +02:00
Luca 07b450a954 feat(setup): per-feature config step after feature selection
When the chosen features need config the wizard can collect, 'Finish' on
the usage step now advances to a lean config step instead of jumping to
the dashboard:
- Invoicing (if Invoices): company/legal name, address, VAT-ID or tax
  number, IBAN, currency → saved to business-profile + a default bank
  account. Carries the bank/VAT legal disclaimer.
- Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/
  user/pass/from → saved to email_configs.
Each section persists only if started, and 'Skip for now' is always
available — soft settings keep their seeded defaults. en + de strings.
2026-07-02 22:10:54 +02:00
Luca a95ee473ae feat(setup): add restore-from-backup branch to the first-run wizard
The usage step now offers 'Migrating from another PicPeak?' → a restore
step that uploads a .picpeak (reusing PicpeakRestoreCard) to clone another
instance onto this fresh one, preserving the account just created. en + de
strings added.
2026-07-02 21:57:50 +02:00
Luca 86324e7da7 feat(backup): fold .picpeak restore into the Restore wizard's Upload source
Removes the redundant standalone .picpeak card. The wizard's 'Upload
Backup' source now splits into two kinds: '.picpeak backup' (the working
portable restore — renders the upload + destructive-confirm flow inline)
and 'Manifest + files' (legacy, still 'Manifest Upload functionality
coming soon'). en + de strings added.
2026-07-02 21:38:48 +02:00
Luca d4b143f313 fix(setup): keep the first-run wizard light regardless of dark mode
The setup page background used var(--color-background), which flips to
#0a0a0a under the .dark class while the wizard card stays hardcoded light
— giving a dark page + light card mismatch in dark mode. Pin the first-run
screen to its intended light branded look (fixed #fafafa bg / #171717 text)
so all three steps render consistently.
2026-07-02 21:29:47 +02:00
Paul Nothaft b04ef216f5 chore(main): release 3.79.1-beta.0 (#729)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 19:03:05 +00:00
Paul Nothaft 4aa6583bae Merge pull request #728 from Luca-Timo/fix/settings-duplicate-mail-import
fix(settings): remove duplicate Mail import that crashes the dev server
2026-07-02 20:59:40 +02:00
Paul Nothaft c8610276c2 Merge pull request #727 from PicPeak/chore/security-nginx-npm-bumps
chore(security): bump frontend nginx to r4 (close 4 HTTP/2 & module CVEs)
2026-07-02 20:53:45 +02:00
Luca cc79b3d9ec refactor(backup): move .picpeak download to the Dashboard tab
Downloading a portable backup is a "make a backup" action, so it belongs
next to "Run Backup Now" on the Dashboard, not under Restore. Split the
combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard
(Restore). The manifest stays bundled inside the .picpeak, so there is no
separate manifest-only download for the portable format.
2026-07-02 20:37:35 +02:00
Luca f57462f798 fix(backup): make .picpeak roundtrip work on Postgres
Two Postgres-only bugs found by a live docker-pg roundtrip (SQLite tests
passed because neither reproduces on SQLite):

- Export: knex `.stream()` pulls in the optional `pg-query-stream` module
  (not bundled) and throws on pg. Switched to a plain per-table `select`
  — works on both engines, no new dependency. Rows are DB metadata
  (blobs live under files/), so holding a table in memory is fine.
- Import: the pg driver returns json/jsonb columns as parsed JS values,
  so re-inserting a scalar like the string "PicPeak" sent it unquoted and
  pg rejected it ("invalid input syntax for type json"). Now introspects
  each table's json/jsonb columns and re-serialises those values before
  insert (pg only; SQLite stores json as TEXT and round-trips as-is).

Verified end-to-end on docker Postgres: export 85 tables, full-override
import, current account preserved, post-backup data removed.
2026-07-02 20:13:18 +02:00
Luca 66d61c87ca feat(backup): .picpeak download + upload-restore UI in Backup Manager
Adds a self-contained "Portable backup (.picpeak)" card to the Restore
tab, completing the GUI-only roundtrip:
- Download: optional "include original photos" toggle + a prominent
  plaintext-secrets warning, streams the file via a blob download.
- Restore: file picker → destructive confirmation modal ("replaces ALL
  data except your current account, cannot be undone") → multipart upload
  to /admin/backup/picpeak/import → success summary. If the backup uses
  external media, shows a banner to reconfigure the mount, with a docs link.
Kept separate from the legacy RestoreWizard (different format/flow). en+de
strings added; dark-mode variants throughout.
2026-07-02 19:45:16 +02:00
Luca 2b66f6d889 feat(backup): upload + restore endpoint for .picpeak
POST /admin/backup/picpeak/import — multipart upload of a .picpeak,
streamed to a temp file (after auth, so unauthenticated requests can't
push a large file to disk), then restored via picpeakImportService with
currentAdminId = the logged-in operator (preserved across the override).
Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to
reconfigure the external-media mount. Temp upload is always unlinked.

Completes the backend half of the GUI-only roundtrip (export download +
import upload). Multipart is already allowed by the CSRF content-type guard.
2026-07-02 19:37:17 +02:00
Luca 2920d82186 feat(backup): .picpeak import/restore (full override, keeps current account)
Receiving half of the roundtrip. picpeakImportService.importFromPicpeak():
- Validates the manifest: rejects non-picpeak files, a newer format, an
  engine mismatch (pg↔pg / sqlite↔sqlite only), and a backup from a NEWER
  schema than this instance (forward-only). knex_migrations absence is
  tolerated (test harnesses).
- Snapshots the current logged-in admin, then wipes + reloads every table
  from the backup NDJSON in one transaction with FK enforcement suspended
  (pg: session_replication_role=replica reset before commit; sqlite:
  defer_foreign_keys). knex_migrations is never touched, so the target's
  schema/migration state is preserved.
- Re-injects the current account so the operator is never locked out; a
  backup admin colliding on email is overwritten with the current creds.
- Restores files/ into storage and detects external-media references so the
  caller can prompt to reconfigure the mount.

Roundtrip integration test proves: backup data restored, current account
survives a full override (different email → added), and the email-collision
case keeps the operator's password.
2026-07-02 19:35:48 +02:00
Luca 422dfe1cc8 feat(setup): add "How will you use PicPeak?" feature-selection step
After the admin account is created (and we're logged in), the wizard now
shows an opt-in feature step instead of jumping straight to the dashboard.
Grouped ticks (Client management / Accounting / Automation) map to the
existing feature flags; galleries/analytics/userManagement stay always-on
and are noted, not listed.

- Selection is saved via the existing authenticated PUT /admin/feature-flags,
  whose server-side applyDependencyRules resolves dependencies (e.g. Invoices
  pulls in Accounting) — the wizard only sends raw ticks.
- Labels/descriptions reuse settings.features.<key>.title/description so
  translations stay in sync (en + de verified for all 14 features).
- Saving is best-effort: on failure the admin still enters the app and can
  set features later in Settings.
- New en/de strings for the usage step.

Option A (lean wizard): this is the feature-selection foundation; per-feature
hard-required config steps + the restore-from-backup branch come next.
2026-07-02 19:31:10 +02:00
Luca 38b3aef63d feat(backup): .picpeak portable export (engine-neutral logical snapshot)
First half of the GUI-only backup roundtrip. Adds a self-describing
".picpeak" archive that can be downloaded from one instance and (later)
re-uploaded to another via the web UI only.

- picpeakExportService.createPicpeak(): dumps every table as NDJSON
  (tables introspected at runtime — no hardcoded list, won't rot), plus
  a manifest (format version, app version, DB engine, latest migration,
  per-table row counts + checksums, includePhotos, contains_secrets),
  plus files/ (business-docs + uploads always; original gallery photos
  only when includePhotos). NDJSON is engine-neutral so the target
  rebuilds schema via migrations then loads rows — enabling pg↔pg /
  sqlite↔sqlite and forward-only auto-migrate.
- GET /admin/backup/picpeak/export?includePhotos= streams the file and
  sets X-Picpeak-Contains-Secrets (the file holds plaintext SMTP pass,
  admin hashes, API keys — the UI must warn).
- Purely additive: no existing backup/restore path is touched.

Integration test proves the archive shape, knex-table exclusion, and
row-count/NDJSON consistency (85 tables on the seed schema).
2026-07-02 19:13:58 +02:00
Luca 5b535f8658 fix(settings): remove duplicate Mail import that broke the dev server
SettingsPage.tsx imported `Mail` from lucide-react twice — in the main
icon block (line 20) and again in a later import (line 58). The
@vitejs/plugin-react babel transform rejects the duplicate with
"Identifier 'Mail' has already been declared", so `npm run dev` crashed
when the module loaded. The production `vite build` (esbuild) silently
dedupes it, which is why CI/Docker builds passed and it went unnoticed.

The two imports overlap only on `Mail`; drop it from line 58, keeping
that line's six unique icons (Briefcase, Receipt, ScrollText, Landmark,
Smartphone, MonitorPlay). Verified: single Mail import remains, prod
build passes, and the vite dev transform of SettingsPage now returns 200
with no "already been declared" error.
2026-07-02 17:37:51 +02:00
Paul Nothaft 64e4925fbb chore(security): bump backend npm 10 -> 11 to patch bundled sigstore/tar
Closes Trivy alerts #375 (sigstore CVE-2026-48815), #321 (@sigstore/core),
#314 (tar) — npm@10 bundles the vulnerable sigstore 3.1.0; npm 11 ships the
patched 4.x. Safe because this npm is CLI-only in the final image: runtime
deps come from the builder stage's node_modules and the entrypoint runs node,
not npm, so the install-behaviour issues that motivated the 10.x pin never run
here. npm 11 requires Node >=22.9 — satisfied by node:22-alpine.
2026-07-02 17:16:09 +02:00
Paul Nothaft 12a9d963f5 chore(security): bump frontend nginx to r4 — close 4 HTTP/2 & module CVEs
Trivy flagged nginx 1.28.3-r1 in the frontend image (alerts #371-374):
- CVE-2026-42055 (HIGH) HTTP/2 heap overflow
- CVE-2026-49975 (HIGH) HTTP/2 DoS
- CVE-2026-9256  (HIGH) rewrite_module code exec / DoS
- CVE-2026-48142 (MED)  charset_module memory disclosure

All fixed in nginx 1.28.3-r4. The Dockerfile already ran 'apk upgrade
--no-cache', but the pushed image predated the fixed package and the layer
was cached on r1. Add an explicit nginx upgrade to force the layer to rebuild
against the current Alpine repos (which now carry r4).
2026-07-02 17:14:29 +02:00
Paul Nothaft 24c287d051 chore(main): release 3.79.0-beta.0 (#726)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 15:12:45 +00:00
Paul Nothaft 681619f0a1 Merge pull request #718 from PicPeak/feat/setup-wizard-unattended
feat: setup wizard + argument-driven unattended install
2026-07-02 17:08:21 +02:00
Paul Nothaft d35c413651 feat(setup): step-by-step wizard + argument-driven unattended install
Restructure picpeak-setup.sh around two clear modes:

- Interactive wizard (run_wizard): asks method → install dir → channel →
  domain → HTTPS handling → admin email → SMTP, then shows a review and
  confirms before installing. Each value already passed as a flag is
  respected and its question skipped.
- Unattended (--unattended + flags): validate_unattended fills defaults and
  fails fast on impossible combos (e.g. --enable-ssl without --domain).
  New flags: --admin-password, --install-dir, --channel.

Align the Docker path with the rest of the project:
- Use the committed docker-compose.production.yml (prebuilt GHCR images) via
  COMPOSE_FILE in .env instead of hand-generating a divergent compose file.
- Drop the broken setup_ssl_docker call (was referenced but never defined).
- Update path pulls images instead of building.

Admin bootstrap follows the browser-first model (#714): by default no
password is written; the one-time /setup token is surfaced (from
data/SETUP_TOKEN or the logs) with browser instructions. --admin-password
keeps the legacy seeded-admin + ADMIN_CREDENTIALS.txt flow for headless runs.

Depends on #714 (setup-token backend + secrets-init in production compose)
for the browser-first + zero-secret behavior at runtime.
2026-07-02 16:44:09 +02:00
Paul Nothaft 6850bd3bef chore(main): release 3.78.0-beta.0 (#725)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 14:35:07 +00:00
Paul Nothaft 97b9853709 Merge pull request #724 from PicPeak/fix/release-automerge-pat
fix: enable release-PR auto-merge with the PAT so releases actually publish
2026-07-02 16:32:04 +02:00
Paul Nothaft bafc96f468 Merge pull request #714 from Luca-Timo/feat/first-run-setup-wizard
feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets
2026-07-02 16:19:33 +02:00
Paul Nothaft e08a33d9ea fix(ci): enable release-PR auto-merge with the PAT, not GITHUB_TOKEN
Auto-merge enabled via GITHUB_TOKEN attributes the eventual merge commit to
github-actions[bot], so recursion prevention suppresses the resulting push to
main — the follow-up release-please run that cuts the tag/release never fires.
Net: the version PR merges but no release/tag/images are ever produced (#719).

Enable auto-merge with RELEASE_PLEASE_TOKEN instead (a real identity) so the
merge triggers the tag-cutting run. Approval stays on GITHUB_TOKEN because it
must be a different identity than the PR author (the PAT) to count as a review.

Observed on #723: merged 3.77.3-beta.0 but no run followed and no tag was cut.
2026-07-02 15:41:25 +02:00
Paul Nothaft 2b2dc6e35c chore(main): release 3.77.3-beta.0 (#723)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-02 13:29:08 +00:00
Paul Nothaft d00d52a221 Merge pull request #722 from PicPeak/fix/release-please-gh-repo
fix: set GH_REPO in release-please auto-merge step
2026-07-02 15:23:49 +02:00
Paul Nothaft e54100ac46 Merge pull request #721 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.77.2-beta.0
2026-07-02 15:23:08 +02:00
Paul Nothaft 0cab43ed89 fix(ci): set GH_REPO in release-please auto-merge step
The auto-merge step runs in a job with no actions/checkout, so gh could not
infer the repository from a git remote and failed with 'not a git repository'
(#719 follow-up). Set GH_REPO=${{ github.repository }} so gh pr list/review/
merge work without a checkout — same fix as the whatsnew workflow (2a5f0a8).

Confirmed working otherwise: with RELEASE_PLEASE_TOKEN set, release PR #721 is
now PAT-authored and its required checks run automatically (no manual approval).
2026-07-02 15:20:07 +02:00
Luca b0912c7427 feat(setup): validate setup token at step 1 before advancing
Previously "Continue" on the token step only checked the field was
non-empty; a wrong token wasn't caught until the final submit, after the
user had filled in email + password. Add a non-burning verify:

- backend: POST /setup/verify-token constant-time compares the token
  without consuming it (createInitialAdmin still claims it atomically on
  submit), gated on no-admin-exists and rate-limited like /setup/admin.
- frontend: step-1 "Continue" calls verifyToken and only advances on a
  valid token; a wrong token shows the invalidToken error on the field,
  429 -> too-many-attempts, 409 -> redirect to login.

Adds integration tests for accept-without-burn / reject / closed-once-set.
2026-07-02 15:18:22 +02:00
Paul Nothaft 95dac43fdf chore(main): release 3.77.2-beta.0 2026-07-02 15:17:15 +02:00
Paul Nothaft fb64ec0910 Merge pull request #720 from PicPeak/fix/release-please-automation
fix: auto-publish release-please PRs without manual approval
2026-07-02 15:16:54 +02:00
Luca 3e69c5df3f fix(setup): match first-run logo size to the login page default
The header logo used a hardcoded 64px frame; the login page renders a
medium (200x150) frame via resolveLoginLogoClasses. Reuse that helper
with the default size so /setup and /admin/login read identically.
2026-07-02 15:03:56 +02:00
Paul Nothaft a3e7232b8e fix(ci): auto-publish release-please PRs without manual approval (#719)
The release PR (authored by github-actions[bot] via GITHUB_TOKEN) sat open
forever: its workflows were held behind 'awaiting approval' and the required
review could not be satisfied by the bot. Both release-please workflows now:

- Use a dedicated ${{ secrets.RELEASE_PLEASE_TOKEN }} (fine-grained PAT) with a
  GITHUB_TOKEN fallback. A PAT-authored PR runs CI automatically (no 'awaiting
  approval') and can be merged without a human.
- Auto-approve (as github-actions[bot], a different identity than the PR
  author) and enable auto-merge on the open release PR, so it publishes once
  checks pass. Skipped when no PAT is configured — falls back to today's manual
  flow, nothing breaks.

A PAT also un-suppresses the tag-push and release-published triggers on
docker-build (GITHUB_TOKEN suppressed them), so add a concurrency group there
to collapse the duplicate same-version builds into one.

Requires (repo/org settings, one-time):
- Create fine-grained PAT RELEASE_PLEASE_TOKEN (contents:write, pull-requests:write).
- Enable 'Allow auto-merge' on the repo (currently off).
- 'Allow GitHub Actions to approve pull requests' — already enabled.
2026-07-02 15:01:52 +02:00
Luca d9b0eb7232 feat(setup): brand first-run screen and split into two-step wizard
Address post-merge UI feedback on the first-run setup screen — the first
screen any new admin sees:

- Use the bundled PicPeak logo (same asset the login page falls back to)
  on the cream brand plate instead of the generic lucide Sparkles icon.
- Split the flow into two steps: step 1 takes only the one-time setup
  token, with the `docker compose logs backend | grep -i "setup token"`
  recovery command shown prominently (with a copy button) directly under
  the field, plus a docs link for when the logs have rotated away; step 2
  collects email + password. A rejected token bounces back to step 1.

en/de strings added; other locales fall back to en.
2026-07-02 14:56:14 +02:00
Paul Nothaft 934cc92bb8 Merge pull request #717 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.77.1-beta.0
2026-07-02 14:51:09 +02:00
Luca 286975dc52 fix(setup): address PR #714 review — password UX, script token, race, nits
Blockers:
- SetupPage now mirrors the server password rule (>=8 with upper/lower/digit) so
  a green client isn't bounced by the server; server errors carry a `field`
  (routes/setup.js) that the client maps to a translated key instead of
  rendering raw English. New i18n: setup.invalidToken, setup.passwordRequirements.
- picpeak-setup.sh: the ADMIN_CREDENTIALS.txt block no longer dead-ends on the
  wizard path — when no legacy admin was seeded it prints the one-time setup
  token (from data/SETUP_TOKEN / docker compose logs) and points at /setup.

Concern:
- createInitialAdmin creates the admin + burns the token in ONE transaction,
  atomically claiming the token (null-if-present, expect 1 row) so a
  double-submit can't create two super_admins. Cross-DB (whereNotNull, trx-only
  writes). Added a concurrency test.

Nits:
- SetupPage redirects to /login when /setup/status errors (no form flash on a
  configured instance).
- Dropped the unused DATABASE_URL from docker-compose.yml.
- Documented why secrets are chmod 644 (three different reader users).
2026-07-02 13:21:55 +02:00
github-actions[bot] 6f5a02b817 chore(main): release 3.77.1-beta.0 2026-07-02 11:01:34 +00:00
Paul Nothaft f5b4aa7a5b Merge pull request #716 from PicPeak/docs/require-ui-screenshots
docs: require screenshots for UI changes in PRs
2026-07-02 13:01:04 +02:00
Paul Nothaft 8ca74776f4 docs: require screenshots for UI changes in PRs
Any PR that changes a user-facing surface must include a screenshot of the
result in the description (before/after where it helps). Reviewers ask for
one before reviewing UI-touching PRs; backend/non-visual changes are exempt.
2026-07-02 11:57:32 +02:00
Luca 415bffa04c feat: zero-config first run — in-browser admin bootstrap + auto-generated secrets
Fresh installs need nothing in .env. See PR description for the full feature.
2026-07-01 14:49:18 +02:00
Paul Nothaft b8b33ae6d6 Merge pull request #713 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.77.0-beta.0
2026-07-01 10:00:29 +02:00
github-actions[bot] e1dae31e16 chore(main): release 3.77.0-beta.0 2026-07-01 07:55:55 +00:00
Paul Nothaft e873f7c98c Merge pull request #712 from PicPeak/chore/conventional-pr-title-guard
feat: admin photos list/grid toggle + upload failure report (#707, #708)
2026-07-01 09:55:37 +02:00
Paul Nothaft 6f95796b7c feat: admin photos list/grid toggle + upload failure report (#707, #708)
Records two features that merged with gitmoji commit subjects and were
therefore skipped by Release Please, so the next beta credits them:
- #707 grid/list layout toggle on the admin Photos tab
- #708 per-file upload failure report in the upload modal

No code change — the features are already on main; this commit only gives
Release Please a Conventional Commit to cut the release from.
2026-07-01 09:38:34 +02:00
Paul Nothaft 4881d2040a ci: validate PR titles against Conventional Commits
Release Please only recognizes Conventional Commit prefixes (feat:, fix:,
...). PRs merged with other conventions (gitmoji, free-form) are silently
skipped, shipping changes with no version bump or changelog entry (see
#707/#708). Fail such PRs early via amannn/action-semantic-pull-request.
2026-07-01 09:38:34 +02:00
Paul Nothaft b8665e1d86 Merge pull request #708 from andredlng/feat/upload-failure-details
Surface which files failed during photo upload
2026-07-01 09:29:19 +02:00
Paul Nothaft f4a1db8b6a Merge pull request #707 from andredlng/feat/photos-list-view
Add grid/list layout toggle to admin Photos tab
2026-07-01 09:00:31 +02:00
André Deuerling 1be871cb9a 🐛 Address review: fix spinner hang + show processing failures
Blocker: the "every file rejected" reset never fired because the backend
returns `upload_id` unconditionally (with count 0), so `anyQueued` was
always true and the completion effect (gated on total > 0) never ran —
modal spun forever. Gate `anyQueued` on `count > 0` so a zero-photo
response takes the terminal reset path.

Concern 1: processing-stage failures were invisible — the modal
auto-closed on clean transfer before the worker reported them. Defer the
settle/close decision to the completion effect (combining transfer +
processing failures), and persist failed photos into `processingFailures`
state before `uploadIds` is cleared, so the rows don't vanish the instant
they appear.

Also: report card gets role="status"/aria-live (nit), and tests now cover
the whole-chunk transfer failure, the clean-settle path, and the
onUploadSettled contract from the real component.
2026-07-01 08:03:01 +02:00
André Deuerling 2a81d992f1 🎨 Address review: persist-on-click + radiogroup toggle
- Persist the layout choice in the toggle click handlers instead of a
  useEffect, so simply opening the Photos tab no longer re-writes the
  value it just read from localStorage (review concern 1).
- Give the Grid/List toggle radiogroup/radio + aria-checked semantics
  so a screen reader announces them as one mutually-exclusive set
  (review concern 2).
- Add a test that mount performs no localStorage write.
2026-07-01 07:55:49 +02:00
Paul Nothaft 22b20e8314 Merge pull request #711 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.76.2-beta.0
2026-06-30 22:57:16 +02:00
github-actions[bot] 79924e10d9 chore(main): release 3.76.2-beta.0 2026-06-30 20:52:33 +00:00
Paul Nothaft 3feed0fae6 Merge pull request #710 from PicPeak/fix/whatsnew-workflow-no-checkout
fix(ci): whatsnew highlights — set GH_REPO so gh runs without a checkout
2026-06-30 22:52:08 +02:00
Paul Nothaft 2a5f0a8601 fix(ci): whatsnew highlights — set GH_REPO so gh runs without a checkout
Release Please Beta on v3.76.1-beta.0 hard-failed at the very first
`gh release view "$TAG"` call:

  failed to run git: fatal: not a git repository
  (or any of the parent directories): .git

The reusable `whatsnew-highlights.yml` (PR #703) doesn't run
actions/checkout — so when `gh` tried to infer the target repo from
the runner's empty workspace it errored out. The first time it ran
against an actual release (#709 → 3.76.1-beta.0), the whole job died
before the deterministic-fallback path could save it.

Two changes, both single-line:

1. `env.GH_REPO: ${{ github.repository }}` at job scope. `gh` honours
   this and won't fall back to parsing `.git/config`, so no checkout
   is needed (the workflow only calls the GitHub API, never reads
   repo files).

2. `continue-on-error: true` on the "Extract Features" step. The
   file's comments say "never let highlights break a release", but
   the original wiring only soft-failed the AI + inject steps. A
   transient API hiccup at extract still hard-failed the whole job —
   defeating the design intent. Match the comment.

Why not just add actions/checkout? It would work, but pulls the whole
repo over the wire on every release just for `gh` to read its own
config. GH_REPO is the lighter idiom.

Net impact today: v3.76.1-beta.0 shipped without the `<!-- whatsnew -->`
block; the app's parseWhatsNew() already falls back to the raw Features
list so the admin "What's New" banner still works. The next beta release
will pick up the polished version.
2026-06-30 22:47:10 +02:00
Paul Nothaft 152952877f Merge pull request #709 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.76.1-beta.0
2026-06-30 22:42:09 +02:00
github-actions[bot] 753e680b1c chore(main): release 3.76.1-beta.0 2026-06-30 20:33:22 +00:00
Paul Nothaft 0b74b51f40 Merge pull request #703 from Luca-Timo/ci/whatsnew-highlights-workflow
ci(whatsnew): generate release highlights via GitHub Models
2026-06-30 22:33:00 +02:00
André Deuerling cc26296c58 🐛 Keep upload modal open when some files fail
The failure report lives inside the upload modal, but the modal
auto-closed the instant the transfer finished (handleUploadComplete →
onClose), unmounting the report before the user could read it — so the
"which files failed" list never actually appeared.

Split the modal's completion callback in two:
- onUploadComplete: refresh the grid only (no close), as bytes land and
  again when processing finishes
- onUploadSettled({ hasFailures }): fired once the transfer settles; the
  modal auto-closes only on a clean upload and stays open (report
  visible) when any file failed

Also reset the transfer UI when nothing was queued (every file failed),
which previously left the modal spinning forever. Add a PhotoUploadModal
test covering close-on-clean vs stay-open-on-failure.
2026-06-30 21:22:49 +02:00
André Deuerling 1b0100cfad Surface which files failed during photo upload
A partial upload only told the admin "some files failed" with no way to
find out which ones — even though the data existed. The backend already
returns per-file rejections (response.errors: [{filename, error}]) and the
progress hook already exposes failedPhotos, but both were dropped.

Add a dismissible failure report to the upload modal listing every file
that didn't make it into the gallery, grouped by stage with its reason:
- rejected: per-file validation rejections from the upload response
  (previously discarded entirely)
- transfer: whole-chunk request failures (now captured with the error,
  not just the filename)
- processing: background-worker failures from useUploadProgress.failedPhotos

Replace the count-only "some files failed" toast with one that points at
the list. Add en/de keys under upload.failures.* and a component test
covering the rejected + processing rows and dismissal.
2026-06-30 21:08:02 +02:00
André Deuerling 46ce59d82e Add grid/list layout toggle to admin Photos tab
The event detail Photos tab (AdminPhotoGrid) only offered a thumbnail
grid. Add a Grid/List toggle in the action bar so admins can scan
photos in a compact, metadata-oriented list.

- New utils/photoViewPrefs.ts persists the choice per admin via
  localStorage (mirrors utils/calendarPrefs.ts), defaulting to grid
- List view is a compact <table> following the established admin
  list pattern (EventsListPage), with responsive column hiding:
  Photo (thumbnail + filename + original + Video/Hidden badges),
  Category (lg+), Uploaded date (md+, via useLocalizedDate),
  Engagement views/downloads/likes (xl+), Feedback rating/comments
  (sm+), Size, and hover Actions (download, delete)
- Rows reuse the existing selection, download, delete and category
  handlers; row click opens the photo viewer
- Toggle buttons use LayoutGrid / List icons with aria-pressed state
- Add en.json + de.json keys under admin.photos (viewMode, gridView,
  listView, columns.*)
- Tests for the persistence util and the toggle's render + persistence
2026-06-30 19:17:26 +02:00
Luca 5582644dc4 fix(whatsnew): decode HTML entities and trim em-dash detail in fallback bullets
The Features-fallback showed raw changelog text, so a commit subject like
'branded URL shortener — /s/<slug> with OG injection' surfaced two problems
in the admin banner:
- release-please escapes <slug> to &lt;slug&gt;; React renders the literal
  entity, so the banner read '/s/&lt;slug&gt;'. Decode the entities
  (&lt; &gt; &amp; &quot; &#39;), &amp; last to avoid double-decoding.
- the technical tail leaked into a user-facing highlight. Drop a trailing
  '— detail' clause (em dash only, so 'mark-paid' is untouched) so the bullet
  reads as the headline 'branded URL shortener'.

Only affects the deterministic fallback; curated <!-- whatsnew --> blocks are
unchanged.
2026-06-30 18:39:21 +02:00
Luca d25178d2e5 ci(whatsnew): let the Models step fail soft so the fallback runs without Models
If GitHub Models is disabled for the org the ai-inference step errors;
without continue-on-error the job would go red and skip the inject+fallback.
Mark it continue-on-error so an unavailable Models cleanly degrades to the
deterministic bullets — the feature now works with Models off, not just on.
2026-06-30 17:28:31 +02:00
Paul Nothaft 627c655a4d Merge pull request #704 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.76.0-beta.0
2026-06-30 17:12:31 +02:00
github-actions[bot] 6439cf2e42 chore(main): release 3.76.0-beta.0 2026-06-30 15:02:34 +00:00
Paul Nothaft a0f7033ffc Merge pull request #702 from PicPeak/feat/branded-short-urls-699
feat(gallery): branded URL shortener — /s/<slug> with OG injection (#699)
2026-06-30 17:01:16 +02:00
Paul Nothaft 3ac88370d6 Merge pull request #701 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.75.1-beta.0
2026-06-30 17:00:10 +02:00
Luca 5aeb6905ac ci(whatsnew): generate release highlights via GitHub Models
Activate the What's New highlights step that condenses each release's
Features into <=8 short bullets and injects a <!-- whatsnew --> block the
app reads (utils/whatsNew.parseWhatsNew), with a deterministic fallback.

Runs as a needs: job inside the release-please workflows rather than on a
standalone release: published trigger, because release-please creates the
release with GITHUB_TOKEN and GitHub never starts new workflow runs from
token-generated events -- a standalone trigger would never fire. Shared as
a reusable workflow_call so the stable and beta channels stay in sync.

Best-effort: continue-on-error + fallback mean it can never break a release.
Requires GitHub Models enabled for the org; until then the fallback is used.
2026-06-30 16:43:54 +02:00
Paul Nothaft 56c2386c90 feat(gallery): branded URL shortener — /s/<slug> with OG injection (#699)
Issue 3 from #699 (@alexvaltchev's report): expose a custom-named short
URL per event that bots scrape for OG previews and browsers redirect to
the underlying gallery. WhatsApp / iMessage / Facebook cache the OG
metadata by the URL they crawl, so the SHORT URL becomes the cache key
— admins can rotate or split-test underlying gallery URLs without
re-pushing a fresh link to clients.

Additive feature; no existing route, table, or column is modified.

## Backend

- `gallery_short_urls` table (migration 150): id, short_slug UNIQUE,
  event_id FK CASCADE, target_path TEXT, created_by/at, hit_count,
  last_hit_at, deleted_at/by. hasTable-guarded so the migration is
  idempotent on re-run.

- `src/services/galleryShortUrlService.js` — validator + CRUD +
  resolver. Slug rules: `/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/`,
  reserved blocklist (admin, api, auth, gallery, og, s, login, ...).
  target_path snapshots at create-time from the event + global
  short-URL toggle, so a later flip of the toggle does NOT silently
  change where existing short URLs resolve.

- `src/routes/adminShortUrls.js` — `GET/POST
  /api/admin/events/:eventId/short-urls`, `DELETE
  /api/admin/short-urls/:id`. Structured errors: 400 INVALID_SLUG,
  409 SLUG_TAKEN (with `suggested`), 404 EVENT_NOT_FOUND. Gated by
  events.view / events.edit + requireEventOwnership.

- `server.js` /s/:shortSlug public route. Bot UA → server-render the
  same OG metadata the existing /og/gallery/<slug> handler produces,
  then override og:url to point at /s/<shortSlug> itself (cache-key
  invariant — social platforms key by the URL they scrape).
  Browser UA → 302 to target_path. Soft-deleted slug → 410 Gone
  (intentional-delete signal, distinct from 404 unknown slug).
  Hit accounting is fire-and-forget.

## Frontend

- `services/shortUrls.service.ts` — list/create/remove.
- `components/admin/ShortUrlsCard.tsx` — per-event card on the
  EventDetailsPage. Form for custom or auto-generated slug, list with
  copy-to-clipboard + soft-delete. SLUG_TAKEN error surfaces the
  service's `suggested` slug with a "use suggested" button.
- i18n: events.shortUrls.* added to EN + DE.

## Tests

78 new tests, all passing:

- `__tests__/utils/galleryShortUrlValidation.test.js` (48) — pure-
  function tests for validateSlug: accepts/rejects, reserved-slug
  blocklist, path-traversal + URL-injection vectors.
- `__tests__/integration/galleryShortUrls.test.js` (19) — service
  layer against a real SQLite DB. Covers custom + auto-generated
  slugs, collision + SLUG_TAKEN + suggested, target_path
  snapshotting (backward-compat invariant), soft-delete + slug
  rotation, hit counting.
- `__tests__/integration/galleryShortUrlRoute.test.js` (11) —
  HTTP-level: 302 redirect for browser UA, 200 + OG HTML for bot UA,
  og:url canonical points at /s/<slug>, 410 for soft-deleted +
  orphaned events, 404 unknown + malformed.

Regression sweep: 47 existing migration-chain integration tests still
pass; migration 150 is additive only.

## Backward compatibility

- Existing `/gallery/<slug>`, `/gallery/<32-hex-share-token>`,
  `/gallery/<slug>/show/<token>`, `/og/gallery/<slug>`,
  `/og/gallery/<slug>/cover` routes are untouched.
- The `/s/` namespace is new; no existing route lives there.
- Migration 150 only ADDs the new table — no ALTERs on existing
  schema, no destructive changes.
- target_path is snapshotted at create-time so flipping the global
  "Use short gallery URLs" setting after a short URL exists does NOT
  change where that short URL resolves.
2026-06-30 16:30:13 +02:00
github-actions[bot] 541b3d32ef chore(main): release 3.75.1-beta.0 2026-06-30 14:09:58 +00:00
Paul Nothaft 25bf7bb523 Merge pull request #700 from PicPeak/fix/og-injection-share-token-and-slideshow-699
fix(og): rich social previews for share-token + slideshow URLs (#699)
2026-06-30 16:09:31 +02:00
Paul Nothaft 1b8747dc82 fix(og): rich social previews for share-token + slideshow URLs (#699)
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.
2026-06-30 16:03:49 +02:00
Luca de789faec5 Merge pull request #698 from Luca-Timo/docs/comparison-pixieset 2026-06-30 12:35:29 +02:00
Paul Nothaft 52dfe2723d Merge pull request #697 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.74.1-beta.0
2026-06-30 11:44:10 +02:00
github-actions[bot] 5925bed761 chore(main): release 3.75.0-beta.0 2026-06-30 09:44:00 +00:00
Paul Nothaft a1a73bf75f Merge pull request #694 from Luca-Timo/feat/whatsnew-notifications
feat(updates): "What's New" highlights after update + pre-update teaser
2026-06-30 11:43:39 +02:00
Luca 3528f6b8b7 Merge pull request #696 from Luca-Timo/docs/contributor-luap
docs(readme): credit @the-luap as creator/lead maintainer

Docs only no change in Codebase
2026-06-30 11:23:02 +02:00
Luca b0439638bd docs(readme): clarify comparison footnotes — $0 cost caveat + Pixieset video cap
- you still bring your own server (own hardware or VPS) and optional domain.
- Pixieset "unlimited" storage is photos only — video is capped per plan (~0–10 h depending on tier).
- Renumber the PicPeak storage footnote (* → **) so the three markers don't collide.
2026-06-30 11:20:37 +02:00
Luca 721f440fa6 docs(readme): add Pixieset to comparison + customer-accounts/CRM/accounting rows 2026-06-30 11:07:58 +02:00
Luca 748238e8ca docs(readme): credit @the-luap as creator/lead maintainer 2026-06-30 11:06:04 +02:00
Paul Nothaft 0191b283d7 Merge pull request #692 from PicPeak/chore/security-bumps-aug-2026-batch
chore(security): close all 27 code-scanning alerts via dep + base-image bumps
2026-06-30 10:58:09 +02:00
Paul Nothaft e48b81fb8d Merge pull request #693 from PicPeak/chore/workflow-required-checks-always-fire
ci: required-check workflows fire on every PR (drop paths filter + post-rename branch list)
2026-06-30 10:53:25 +02:00
Luca 500cf8522e feat(updates): "What's New" highlights after update + pre-update teaser
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).
2026-06-30 02:31:13 +02:00
Paul Nothaft a40ab6a9b1 ci: required-check workflows now fire on every PR (no paths filter)
Branch protection on `main` + `stable` lists `upgrade-from-bootstrap`
and `fresh-install` as REQUIRED checks. The producing workflows had
`paths:` filters in their `pull_request` triggers, so they correctly
skipped on PRs that didn't touch migrations / package.json. But a
skipped workflow doesn't satisfy a required check — it leaves the
status "missing", which blocks merge on every unrelated PR.

Concretely surfaced on PR #692 (security bumps): all 12 visible checks
were green, but the merge button was blocked because the two
path-filtered workflows skipped and their required-check names never
reported.

This PR drops the `paths:` filter from both workflows so they always
fire on PRs against `main` + `stable`. Costs:
- `schema-drift` (`upgrade-from-bootstrap`): ~75 s per PR (Postgres
  service boot + migrate:safe run + schema assertion).
- `install-smoke` (`fresh-install`): ~2 min per PR (full Docker
  Compose boot + login).

Both are buying unconditional safety nets on the install + migration
paths, which is what the required-check gate is supposed to model.

Also fixes the trigger branch list while in the file: `[main, beta]`
→ `[main, stable]`, completing the post-#669 rename for these two
workflows that were missed in PR #686.

## What this does NOT fix

`GitGuardian Security Checks` is the third required check that's
currently missing on PRs — but that's a separate problem. The
GitGuardian GitHub App was installed at the user-account level
(`the-luap`) before the org transfer and didn't move with the repo.
Re-installing it on the org via the GitHub Marketplace is a UI step
the maintainer needs to do; can't be done via API.
2026-06-30 00:07:44 +02:00
Paul Nothaft 7546f104a3 chore(security): close 27 code-scanning alerts via dep + base-image bumps
Single PR closing every open code-scanning alert at
https://github.com/PicPeak/picpeak/security/code-scanning. Both repos go
from 27 open alerts → 0 across direct deps, transitive deps, and build-
time bundled deps.

## Backend (`backend/package.json` + overrides)

Direct dep bumps:
- axios          1.15.2   → 1.16.0       (closes 9 alerts: 7 high + 1 med + 1 low)
- nodemailer     8.0.10   → ^9.0.1       (closes 1 high — SSRF + file-read via raw option)
- multer         2.1.1    → 2.2.0        (closes 2 alerts: 1 high + 1 med)
- form-data      4.0.5    → 4.0.6        (closes 1 high)
- tar            ≥7.5.13  → ≥7.5.16      (closes 1 med)
- postcss        8.5.6    → 8.5.10       (closes 1 med)
- i18next-http-backend  3.0.2  → 3.0.5   (closes 1 med — backend lagged frontend)
- js-yaml        4.1.1    → ^4.2.0       (closes 1 med)
- joi            17.13.3  → ^17.13.4     (closes 1 med)

Overrides updated to match deps (npm rejected the install otherwise) +
nodemailer ^9.0.1 added as override so imapflow + mailparser transitive
bundling of older nodemailer is also fixed. Babel devDep auto-bumped via
`npm audit fix` (low-severity arbitrary file read).

Backend npm audit: 0 vulnerabilities.

## Frontend (`frontend/package.json`)

Direct dep bumps:
- axios                 1.15.2  → 1.16.0
- postcss               8.5.6   → 8.5.10
- i18next-http-backend  3.0.5   → 3.0.5  (already current — kept for parity)

`npm audit fix` swept up 12 transitive issues at the same time:
- vitest (1 critical — file read on UI server)
- vite (2 high — fs.deny bypass, NTLM hash via launch-editor)
- ws (2 high — uninitialized memory + DoS)
- dompurify (8 mod — multiple IN_PLACE / hook-pollution XSS vectors)
- react-router-dom + react-router (1 mod transitive)
- esbuild (1 mod — dev server file read)
- @babel/core (1 low)

Frontend npm audit: 0 vulnerabilities.

## Frontend Dockerfile

- Build stage: `node:20-alpine` → `node:22-alpine`

Closes the npm-bundled CVE class (picomatch, ip-address, brace-expansion,
@sigstore/core, tar) that came from Node 20's older bundled npm. Matches
the backend Dockerfile base. The nginx serving stage stays at
`nginx:1.28-alpine` — that tag is rolling, so the next build picks up
the fixed 1.28.3-r4 layer that closes the 4 nginx CVEs.

## Verification

- Backend: `npm audit` → 0 vulnerabilities 
- Frontend: `npm audit` → 0 vulnerabilities 
- Backend Jest (workflow engine, rounding, WhatsApp): 47/47 pass 
- Frontend Vitest: 84/84 pass 
- `frontend npm run build`: succeeds 
- nodemailer 9 sanity check: our usage is `createTransport({host,port,secure,auth})`
  + `sendMail({from,to,subject,html,text})` — we don't touch the `raw`
  option that 9.x tightened, so the major bump is API-compatible.
2026-06-29 23:26:32 +02:00
Paul Nothaft a24821de55 Merge pull request #691 from PicPeak/chore/bypass-size-gate
ci: bypass size gate — cap self-merge PR size for bypass users
2026-06-29 23:15:45 +02:00
Paul Nothaft 806b1ac921 ci: bypass size gate — cap self-merge PR size for review-bypass users
@Luca-Timo is on main's review-bypass list so he can self-merge small
bugfixes without waiting for a maintainer review. The bypass list alone
is binary (he can merge anything), so this adds a complementary required
status check that fails when a bypass user's PR exceeds a configured
line-count threshold — blocking merge for genuine features while leaving
small bugfixes flowing.

How it works:
- Trigger: pull_request_target (so the workflow runs in the base repo's
  context with permissions to write a check status — script never
  executes PR code, so fork-PR-attack-safe).
- For PRs authored by a bypass user (default: @Luca-Timo):
    - linesChanged = additions + deletions
    - If ≤ LINE_LIMIT (300): check = success → bypass works → self-merge OK
    - If > LINE_LIMIT: check = failure → required-check gate blocks merge
      regardless of bypass; needs a maintainer review.
- For everyone else: check = success ("not applicable"). They go through
  the normal review path and are unaffected.

Both constants (LINE_LIMIT, BYPASS_USERS) are at the top of the workflow
for easy tuning.

After this lands on main, a separate API step adds 'bypass-size-gate' to
the main branch's required_status_checks list so the gate is actually
enforced. Until that's in place the check runs but doesn't block.
2026-06-29 23:13:09 +02:00
Paul Nothaft beae46e408 Merge pull request #689 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.74.0-beta.0
2026-06-29 22:44:39 +02:00
github-actions[bot] 5ba45753c1 chore(main): release 3.74.0-beta.0 2026-06-29 20:39:22 +00:00
Paul Nothaft 14bd3e1a6c Merge pull request #688 from PicPeak/chore/readme-migration-banner
docs: prominent migration banner at the top of README
2026-06-29 22:29:09 +02:00
Paul Nothaft 5839bba72a docs: prominent migration banner at the top of README (#669)
GitHub-flavored `> [!IMPORTANT]` callout right below the title, before
the badges/hero block, so it's the first thing a visitor or repo browser
sees in the rendered README. Mirrors the in-app banner (#687) so an
operator gets the same message whether they're browsing the repo or
logged into the admin dashboard.

Body covers:
- Image-path change with the literal new path
- Branch rename (beta → main, main → stable) with auto-redirect note
- Link to docs/migration-to-org.md for the exact compose-file edit

Remove (or downgrade to a regular note) after the migration window
settles, same lifecycle as the in-app banner constant.
2026-06-29 22:24:56 +02:00
Paul Nothaft 02133478bd Merge pull request #687 from PicPeak/chore/in-app-migration-banner
feat(admin): in-app migration banner for the org rename
2026-06-29 22:22:52 +02:00
Paul Nothaft 297c2d3df0 Merge pull request #686 from PicPeak/chore/post-rename-workflow-triggers
chore: workflows + RELEASING.md for the post-rename branch model
2026-06-29 22:21:56 +02:00
Paul Nothaft 166ef47611 Merge pull request #685 from PicPeak/chore/post-org-move-docs-and-contributing
docs: branch model + migration-to-org guide + PR template
2026-06-29 22:21:24 +02:00
Paul Nothaft 2a4bf3b868 feat(admin): in-app migration banner for the org rename (#669)
One-time banner shown at the top of the admin layout to surface the org
rename + GHCR registry change for operators who haven't read the release
notes. Sits right below the existing maintenance banner — same pattern.

## What it looks like

Blue, dismissible banner with a short body:

> PicPeak's image registry has moved
> Update your docker-compose.yml to pull from
> ghcr.io/picpeak/picpeak/{backend,frontend} — the old path is no longer
> being updated. [See migration notes]

The link goes to `docs/migration-to-org.md` on the new org repo.

## Design choices

- **No backend feature flag.** A hard-coded `MIGRATION_BANNER_ENABLED`
  constant in the component file (1 line) gates global display. After
  ~1 quarter, flip it to false (or drop the mount in `AdminLayout.tsx`)
  in a small follow-up PR. A backend `app_settings` row + Settings UI
  toggle would be overkill for a one-time migration event.

- **Per-admin dismissal via localStorage.** Key is `picpeak:migration-banner:v1`
  (versioned so a future "we've moved AGAIN" banner can show without
  inheriting earlier dismissal). Wrapped in try/catch so private-mode
  browsers + storage-quota-exceeded errors don't crash the layout.

- **EN + DE strings** under a new top-level `migrationBanner` namespace.
  Other locales (fr, nl, pt, ru) fall through to EN — `migrationBanner.*`
  keys aren't translated there yet, deliberate (per #669 the ops
  banner is operator-facing and admins reading EN/DE is the majority).

- **Reuses `common.dismiss`** for the close-button aria-label.

## Test plan

- [ ] Frontend `npm run build:check` passes (TS + build)
- [ ] Open admin dashboard in EN → banner shows at top, below header,
      above main content
- [ ] Switch to DE → banner shows German strings
- [ ] Click dismiss → banner hides, doesn't re-appear on hard refresh
- [ ] Clear localStorage `picpeak:migration-banner:v1` → banner returns
- [ ] Flip `MIGRATION_BANNER_ENABLED` to false → banner doesn't render
      for anyone, regardless of dismissal state

Refs #669.
2026-06-29 22:19:22 +02:00
Paul Nothaft 3ca6378bd7 chore: workflows + RELEASING.md for the post-rename branch model
After the org move + branch rename (#669):
  beta  → main   (active development)
  main  → stable (curated release channel)

This PR rewires the workflows that referenced the old branch names so
release-please and the Docker build target the right channels.

## Workflow changes

### `.github/workflows/docker-build.yml`

- **Push triggers**: `[main, beta]` → `[main, stable]` (both `push.branches`
  and `pull_request.branches`). `beta` no longer exists; `stable` is the
  curated channel that should also produce builds.
- **`is_prerelease` detection**: pre-release context was decided by
  `refs/heads/beta`; now decided by `refs/heads/main` (active dev →
  prerelease, `-beta.N` version suffix unchanged).
- **`:latest` + `:stable` tagging**: were gated on `{{is_default_branch}}`
  (which used to be `main` = stable channel). Default branch is now `main`
  = active dev, so the implicit gate would have aliased `:latest` to dev.
  Both tags now explicitly gate on `refs/heads/stable` OR a non-prerelease
  release tag.
- **`:beta` tag**: REMOVED. Active-dev pulls are `:main` (auto-generated
  by `type=ref,event=branch`). The pre-rename `:beta` tag remains frozen
  at its last build under Option B / #669 — operators are expected to
  update to `:main` or pin to a versioned tag.

### `.github/workflows/release-please.yml`

- `branches: [main]` → `branches: [stable]`. This is the **stable**
  release-please workflow (uses `release-please-config.json`); after the
  rename, the stable channel lives on the `stable` branch.

### `.github/workflows/release-please-beta.yml`

- `branches: [beta]` → `branches: [main]`.
- `target-branch: beta` → `target-branch: main`.
- This is the **pre-release** release-please workflow (uses
  `release-please-config-beta.json`, `prerelease: true`); after the rename,
  pre-releases are cut from the new `main` (active dev). The version-suffix
  scheme stays `-beta.N` so existing operator pins keep working.

## RELEASING.md

Rewrote the TL;DR, "How a stable release is cut", and hotfix path to
reference the new branch names. Added a one-line "branch model background"
note pointing at #669 so future maintainers know why `main` means active
dev (the opposite of what some projects use). Filename conventions:
`release/X.Y.Z-merge-from-main` (was `…-from-beta`); promotion PR title
`promote main → stable as vX.Y.Z` (was `promote beta → main`).

## Why combined with PR A's content as a single PR

Originally planned as two PRs (B = workflow triggers, C = release-please
reconfigure). Splitting wasn't worth it: the configs are branch-agnostic
(`release-please-config.json` and `release-please-config-beta.json` don't
mention branch names internally), and not bundling them meant a window
where the stable release-please workflow would fire on pushes to the new
`main` (active dev) — exactly the wrong place. Single PR closes that gap.

## Versioning scheme — kept

No version-scheme decision needed. The `-beta.N` suffix on pre-release
versions is preserved (existing operator pins like `v3.71.3-beta.0` keep
working). If a `v4.0.0-pre.N`-style reset is desired later, that's a
separate PR with explicit operator-comms attached.
2026-06-29 22:15:31 +02:00
Paul Nothaft d606fcd5a4 docs: branch model + migration-to-org guide + PR-template target hint
Operator + contributor docs for the post-org-move world. None of these
files reference the legacy branch names (`beta` / old `main` meaning) —
they describe the new shape (`main` = active dev, `stable` = curated
release channel), so they're correct from the moment the rename happens.

Three additions/edits:

1. `docs/migration-to-org.md` (new) — operator-facing one-pager that the
   in-app migration banner + the OLD GHCR package URLs (now 404) can
   point at. Walks through the single `docker-compose.yml` edit needed.

2. `CONTRIBUTING.md` — new "Branch model" section explaining which
   branch to target (`main` for features + most fixes; `stable` only
   for small, surgical bugfix backports). Updates the "fork from beta"
   step to "fork from main". Updates the release-process paragraph to
   describe the two-channel model instead of the old beta→main promote.

3. `.github/PULL_REQUEST_TEMPLATE.md` — adds a target-branch hint at
   the top of the template (HTML comment so it shows during PR
   composition but doesn't render in the merged PR body).
2026-06-29 22:05:56 +02:00
Paul Nothaft 448da95020 Merge pull request #684 from PicPeak/chore/migrate-image-registry-to-picpeak-org
chore: migrate Docker registry + GitHub URLs to PicPeak org
2026-06-29 20:25:15 +02:00
Paul Nothaft 0205c7dcce chore: migrate Docker registry + GitHub URLs to PicPeak org
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).
2026-06-29 20:20:13 +02:00
Paul Nothaft 5ebe126970 Merge pull request #683 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.73.0-beta.0
2026-06-29 20:00:07 +02:00
github-actions[bot] 578a73f352 chore(beta): release 3.73.0-beta.0 2026-06-29 17:57:55 +00:00
Paul Nothaft 86475582e8 Merge pull request #682 from Luca-Timo/fix/invoice-draft-and-nits
CRM: held invoices read as "Draft", + mark-paid / dashboard / reminder fixes
2026-06-29 19:57:27 +02:00
Luca e4367e028a fix(invoices): badge held (unsent, no send date) invoices as "Draft"
The earlier change only relabeled is_monthly_draft rows. But a per-event
invoice created from hours is status 'scheduled' with scheduled_send_at = NULL
and is_monthly_draft = false — it never auto-ships (the scheduler only picks
rows with scheduled_send_at <= now), yet it still read "Scheduled" on the
customer panel + lists.

Add a shared isDraftInvoice() helper (scheduled && no send date, or a
monthly/manual accumulator) and use it for the badge in the Bills list, the
invoice detail header, and the customer profile's invoice panel. A scheduled
invoice WITH a future send date keeps "Scheduled".
2026-06-29 19:39:40 +02:00
Luca d1c9e02bcf feat(dashboard): revenue "year" tile toggles 365 days ↔ calendar YTD
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.
2026-06-29 19:39:40 +02:00
Luca e96ef4c5a3 fix(invoices): add bank transfer to the mark-paid method list
The mark-paid dialog offered Cash / Card / PayPal / TWINT but not bank
transfer — the default method for the QR-bill / IBAN invoices picpeak issues
(createInvoice even falls back to 'bank_transfer'). Added it as the first
option. Backend already accepts paymentMethod as a free string, so no API
change; i18n bills.payment.methods.bankTransfer (de "Überweisung").
2026-06-29 19:39:40 +02:00
Luca ca0944293f fix(invoices): show "Draft" on the invoice detail page for accumulator drafts
Follow-up to the Bills-list change: the invoice detail header still printed
"Scheduled" for a running monthly/manual draft (is_monthly_draft). It already
had a separate monthly-draft badge, but the status pill itself now reads
"Draft" too, matching the list and the Billed-chip link target.
2026-06-29 19:39:40 +02:00
Luca e457656b9d feat(invoices): surface monthly/manual accumulator drafts in the Bills list
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").
2026-06-29 19:39:40 +02:00
Luca b9d91385b4 fix(reminders): wrap is_active/is_archived wheres in formatBoolean
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.
2026-06-29 19:39:40 +02:00
Paul Nothaft 06f4c109bc Merge pull request #680 from Luca-Timo/fix/invoice-pdf-multipage
Fix/invoice pdf multipage
2026-06-28 22:17:07 +02:00
Paul Nothaft 23d1f5d609 Merge pull request #681 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.72.0-beta.0
2026-06-28 21:57:58 +02:00
github-actions[bot] c4ed88b085 chore(beta): release 3.72.0-beta.0 2026-06-28 19:53:32 +00:00
Paul Nothaft ec33ec7670 Merge pull request #679 from Luca-Timo/feat/booking-cutover
feat(workflows): booking cutover — wire booking actions + hold documents behind approval gates
2026-06-28 21:53:09 +02:00
Luca c2bc2b098e fix(invoices): show sub-cent Rundung in the editor totals preview
The live totals panel in the quote/invoice editor (LineItemsTable) summed the
per-line rounded totals and showed that as Total — so with crm_invoice_round_total
on, a 4 × (2.5h @ 32.25) invoice previewed CHF 322.52 while the saved invoice +
PDF correctly show 322.50 with a Rundung row. The preview now mirrors the backend.

- LineItemsTable gains a `roundTotal` prop. When set, it computes the clean net
  (full-precision sum rounded once — same rule as backend
  utils/invoiceRounding.cleanNetMinor, including the migration-119 priced
  sub-item override), shows a "Rundung" row for the drift, and folds it into the
  VAT base + Total. Off ⇒ unchanged (no row).
- Bill + Quote editors pass roundTotal from appSettings.crm_invoice_round_total.
- i18n: crm.lineItems.rounding (de "Rundung", en "Rounding").

The saved-invoice detail view already shows the stored clean total, so no change
there.
2026-06-28 17:28:21 +02:00
Luca 4670292139 feat(invoices): optional sub-cent rounding reconciliation ("Rundung" row)
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).
2026-06-28 15:53:44 +02:00
Luca 2205b0bd68 fix(pdf): correct multi-page invoice/quote layout + drop IBAN dup under Swiss QR
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).
2026-06-28 14:49:50 +02:00
Luca 6e20d58487 fix(workflows): make the dashboard pending-approvals card items clickable too
The dedicated Approvals page rows open the underlying document on click, but the
identical card on the admin dashboard didn't — so 'clickable approvals' only half
worked depending on where you looked. Apply the same treatment: the info area is
now a button that navigates to the run entity's detail page (quote -> /admin/quotes/:id,
invoice -> /admin/bills/:id, etc.), reusing the workflows.approvals.openEntity
tooltip. Confirm/Deny stay separate; items with no mappable entity render as plain
text.
2026-06-28 14:34:36 +02:00
Luca 539a83711d fix(workflows): defer quote.accepted/declined emit until the 15-min response window locks
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).
2026-06-28 14:30:26 +02:00
Paul Nothaft e6f29b6345 Merge pull request #678 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.3-beta.0
2026-06-27 21:56:11 +02:00
github-actions[bot] 74d53b22b1 chore(beta): release 3.71.3-beta.0 2026-06-27 19:50:21 +00:00
Paul Nothaft f01754247c Merge pull request #676 from the-luap/fix/whatsapp-public-events-route-647
fix(events): wire customer notifications into both public API entry points (#647)
2026-06-27 21:50:00 +02:00
Paul Nothaft 54c52d3cf7 Merge pull request #677 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.2-beta.0
2026-06-27 21:49:30 +02:00
github-actions[bot] 5c04137a6b chore(beta): release 3.71.2-beta.0 2026-06-27 19:45:27 +00:00
Paul Nothaft c8714ca42f Merge pull request #674 from Luca-Timo/feat/workflow-engine
fix: event-reminder, email-language & gallery-publish bugs surfaced during workflow testing
2026-06-27 21:45:07 +02:00
Paul Nothaft 511d647eec fix(events): wire customer notifications into both public API entry points (#647)
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.
2026-06-27 21:28:56 +02:00
Luca 882cfc0661 fix(workflows): held booking invoices are 'scheduled', not 'pending_delivery' — so send_document can issue them
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).
2026-06-27 12:36:35 +02:00
Luca 7727b6714b feat(workflows): make approval rows clickable to open the underlying document
Each approval asks the admin to confirm/deny, but they couldn't see what they
were approving. The row's prompt/meta area is now a clickable button that
navigates to the run entity's detail page (quote -> /admin/quotes/:id, invoice
-> /admin/bills/:id, event/contract/customer likewise) so the admin can review
before deciding. Confirm/Deny stay as separate buttons; rows whose entity has no
detail route (or no entity) render as plain, non-clickable text. Adds the
approvals.openEntity tooltip string (en + de).
2026-06-27 01:03:31 +02:00
Luca 9414b42b7f feat(workflows): implement remaining stub actions (prepare_quote, prepare_gallery, reserve_date)
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.
2026-06-27 00:49:35 +02:00
Luca 4faf5a344a feat(workflows): implement prepare_event so booking_full/booking_simple are enableable
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.
2026-06-27 00:08:05 +02:00
Luca cf424efb4a feat(workflows): wire booking document actions (prepare_invoice/contract + send_document)
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.
2026-06-26 23:48:12 +02:00
Paul Nothaft 4212062c12 Merge pull request #673 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.1-beta.0
2026-06-26 17:03:18 +02:00
Luca f54ec66d05 Merge pull request #337 from Luca-Timo/feat/workflow-engine
Feat/workflow engine
2026-06-26 17:02:05 +02:00
Luca 3356b22688 Merge remote-tracking branch 'origin/beta' into feat/workflow-engine
# Conflicts:
#	frontend/src/components/admin/PublishGalleryDialog.tsx
2026-06-26 17:01:16 +02:00
Luca aa3471efe1 fix(gallery): publish dialog stuck for password-protected galleries with no inline email
handleSubmit required a password whenever requirePassword was true, but the
password field only renders on the inline-email path (requirePassword &&
customerEmail). For a password-protected gallery with no inline email the field
was hidden, so submit blocked on the missing password and the dialog never
closed. Gate password collection + validation on a single `needsPassword`
(requirePassword && customerEmail); the no-inline-email path publishes without
re-entering the password (existing hash kept, customer reaches it via portal).
2026-06-26 15:28:58 +02:00
Luca c657892bc8 feat(gallery): publish notifies assigned customer accounts via the account email
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.
2026-06-26 15:26:28 +02:00
github-actions[bot] 218b0dfd94 chore(beta): release 3.71.1-beta.0 2026-06-26 11:05:06 +00:00
Paul Nothaft 748af98f3d Merge pull request #671 from the-luap/fix/publish-dialog-cta-overflow-670
fix(admin): stack publish-gallery dialog CTAs so the German label fits (#670)
2026-06-26 13:04:45 +02:00
Luca 3ccaed06a2 feat(crm): pre-event reminder falls back to the assigned customer account
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.
2026-06-25 20:01:40 +02:00
Luca 10559fd68e fix(email): resolve recipient language from the queue row's event_id, not just email_data
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.
2026-06-25 19:50:08 +02:00
Paul Nothaft dddf932f13 Merge pull request #672 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.71.0-beta.0
2026-06-25 19:26:31 +02:00
Luca 250b240337 fix(crm): pre-event reminder passes raw event_date (fixes "Invalid Date" in the email)
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.
2026-06-25 19:22:04 +02:00
github-actions[bot] ef5247d013 chore(beta): release 3.71.0-beta.0 2026-06-25 17:17:34 +00:00
Paul Nothaft 15be3b8d32 Merge pull request #667 from Luca-Timo/feat/workflow-engine
feat: admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome)
2026-06-25 19:17:03 +02:00
Luca 675e41a2f7 feat(workflows): route webhook node through the delivery pipeline (full Option 1)
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.
2026-06-25 18:44:27 +02:00
Paul Nothaft ea2852dcb0 fix(admin): stack publish-gallery dialog CTAs so the German label fits (#670)
Reporter @the-luap hit the German `Veröffentlichen & Kunden benachrichtigen`
button overflowing the modal footer in the publish dialog. Two failure modes
chained:

1. The footer was a `flex` row with two `flex-1` buttons inside a
   `max-w-md` (448 px) modal. Default `min-width: auto` on flex children
   meant the primary button kept its content width (~340 px including the
   paper-airplane icon + padding) and pushed the row past the modal frame.

2. Adding `min-w-0 whitespace-normal` doesn't help — the base `.btn` class
   has `@apply ... whitespace-nowrap` (`index.css:149`) which wins over a
   utility className via the Tailwind CSS cascade order. So the text won't
   wrap, the button silently extends past the modal frame, no overflow
   indicator. Verified with `getComputedStyle().whiteSpace = 'normal'` and
   the button still rendering as one ~340 px wide line at ~224 px allocated
   space.

Fix: stack both buttons vertically (`flex flex-col-reverse gap-3`). Primary
appears on top visually (col-reverse), cancel below — standard
confirmation-dialog pattern (Material, Headless UI, Radix all do this for
single-action dialogs). Works in every locale and viewport regardless of
label length. No side-by-side row to overflow.

Tried two prior shapes that didn't hold:
- `flex-col-reverse sm:flex-row` with `sm:flex-1 min-w-0 whitespace-normal`
  on the primary: still overflowed silently because of the
  whitespace-nowrap cascade above.
- `flex-col-reverse sm:flex-row sm:justify-end` with content-width buttons:
  `justify-end` doesn't constrain a row whose content sum exceeds the
  container; row just pushes left of the modal.

Bumping the modal to `max-w-lg` (or wider) was also considered and rejected:
matching modal width is asymmetric (every other admin dialog stays at
`max-w-md`), and any locale longer than German would re-hit the wall.
Stack-always is the only shape that handles every locale + every viewport
without per-language tuning.

Verified end-to-end against a dockerised dev backend:
- DE + EN × desktop (1280px) + mobile (375px) — all four show primary on
  top, cancel below, both inside the modal frame, no overflow.

Lint + tsc + full vitest suite (84/84) clean.

Closes #670.
2026-06-25 18:06:08 +02:00
Luca af7eea8b43 fix(workflows): wire a real, SSRF-guarded webhook action (was a silent no-op)
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.
2026-06-25 18:00:12 +02:00
Luca 415c93a512 fix(event-types): renaming a type's slug cascades to events, quotes + reminder template
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.
2026-06-24 13:52:42 +02:00
Luca 10d091b55e feat(workflows): pre-event reminder picks the template GROUP on the block, type stays automatic
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.
2026-06-24 11:26:02 +02:00
Luca 5fbe514db6 fix(crm): pre-event reminder resolves recipient from the event row, not a non-existent column
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.
2026-06-24 01:30:35 +02:00
Luca dee8d40bb3 fix(workflows): matchFilter strict equality + accurate comment
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.
2026-06-23 23:36:56 +02:00
Luca 440b2b379f test(workflows): cover review fixes + update for disabled-by-default posture
- 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
2026-06-23 23:36:17 +02:00
Luca c5f131cec3 feat(workflows): warn when disabling a built-in (reverts to legacy, not off)
Confirm dialog on the list page when toggling a built-in OFF, clarifying it
reverts to the previous built-in/legacy behaviour rather than turning the
automation off (review concern #4). The enable-refusal for unimplemented flows
surfaces via the existing toggle error toast (backend 409). EN + DE strings.
2026-06-23 23:36:17 +02:00
Luca d927464778 fix(workflows): harden graph validation + refuse enabling unimplemented flows
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.
2026-06-23 23:36:05 +02:00
Luca 5893ecb27a fix(workflows): ship built-ins disabled for first beta + enabled-based mutex + admin sentinel
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).
2026-06-23 23:35:56 +02:00
Luca 98ab717043 fix(workflows): close review blockers — prefetch-safe approvals + loud gate-edge failure
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'.
2026-06-23 23:35:45 +02:00
Luca d14f1d850c feat(workflows): per-quote booking-workflow picker + quote→invoice (no gallery) built-in
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.
2026-06-23 23:15:31 +02:00
Paul Nothaft 13e948e2eb Merge pull request #666 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.70.0-beta.0
2026-06-23 18:46:23 +02:00
github-actions[bot] 488735d0cc chore(beta): release 3.70.0-beta.0 2026-06-23 16:42:29 +00:00
Paul Nothaft 83461fe5d4 Merge pull request #665 from the-luap/feat/pluggable-trackers-663
feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
2026-06-23 18:42:02 +02:00
Luca eec262b0a7 copy(crm): late-fee toggle reads "every reminder after the first"
The fee accumulates on every fee-bearing reminder (2nd onward), not just the
2nd — relabel the toggle (EN + DE + code fallback) to match the behaviour.
2026-06-23 18:22:18 +02:00
Paul Nothaft 162c2a98d8 Merge pull request #664 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.69.1-beta.0
2026-06-23 18:14:19 +02:00
Paul Nothaft ab501459a4 feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
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.
2026-06-23 18:11:58 +02:00
Luca 23098127a8 fix(crm): quote→event fallback resolves an ACTIVE event type, never hardcoded 'wedding'
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.
2026-06-23 18:02:11 +02:00
Luca f78671fc6c feat(crm): event-type dropdown on quotes; quote→event uses it (no more hardcoded 'wedding')
Quotes now carry an event type (migration 146: quotes.event_type, the
event_types.slug_prefix), chosen from the active event-types catalog in the
quote editor's Event section. convertToEvent reads it instead of the
unconditional hardcoded 'wedding': quote.event_type → crm_default_event_type
setting → 'wedding' as last-resort seeded fallback. When the booking flow's
prepare_event is wired, it reads the same field.

Backend: createQuote/updateQuote persist event_type (hasColumn-guarded);
adminQuotes route accepts + returns eventType. Frontend: FormState + payload +
load + a catalog-sourced dropdown ("— Use default —"); EN/DE strings.
2026-06-23 17:52:50 +02:00
github-actions[bot] 9ae43fc991 chore(beta): release 3.69.1-beta.0 2026-06-23 15:44:38 +00:00
Paul Nothaft 349f566e87 Merge pull request #662 from the-luap/fix/analytics-dashboard-bugs-661
fix(analytics): admin dashboard reads correct fields + Umami device API (#661)
2026-06-23 17:44:10 +02:00
Paul Nothaft 7534447b6c fix(analytics): admin dashboard reads correct fields + Umami device API (#661)
Reporter @alexvaltchev hit three independent bugs on the Analytics
Dashboard. All three fixed in one PR; pluggable-tracker support
(Rybbit, Plausible, etc.) left for a separate discussion.

## Bug A — Summary cards showed 0

Two layers, both fixed.

**Frontend** (`AnalyticsPage.tsx:142-149`): the cards summed
`chartData[].views/uniqueVisitors/downloads`. The backend now (and
already) emits a dedicated `totals` object computed via separate
COUNT queries, which is what the cards should read. Postgres returns
counts as strings, so coerce via `Number()`.

**Backend** (`adminDashboard.js:268-282`): the chartData merge used
`dateObj.date === row.date`. On Postgres, pg's driver auto-converts
`DATE(timestamp)` to a JS Date object — the string-equality match
failed silently and `chartData` stayed all-zero on every Postgres
install with traffic. Added a `normaliseDateKey()` helper that
returns YYYY-MM-DD regardless of driver shape, plus `Number()`
coercion on the counts. SQLite path unchanged.

## Bug B — "Umami Not Configured" banner despite valid config

`AnalyticsPage.tsx:90` did `settings.reduce(...)` on the
`/admin/settings` response. That endpoint returns a
key/value **object** (verified at `adminSettings.js:108-149`), not
an array, so `.reduce` threw `data.reduce is not a function` and
the catch silently rendered the "Not Configured" banner even on
perfectly-configured installs. Read the umami keys directly off the
response object.

## Bug C — Device breakdown 0/0/0

Two-pronged fix.

**Primary path — Umami device API** (`services/umamiClient.js`,
wired into `adminDashboard.js`). When the admin provides an Umami
v2 API key (new setting `analytics_umami_api_key`), the backend
fetches the per-period device breakdown from Umami's
`/api/websites/:id/metrics?type=device` endpoint. Umami tracks
devices natively — far more accurate than our coarse user-agent
heuristic. The new `devicesSource` field in the response lets the
UI hint at where the numbers came from.

**Fallback hardening — local heuristic** (`adminDashboard.js:296-320`).
The existing access_logs `LIKE '%Mobile%' / '%Tablet%'` query stays
in place as a fallback for installs without Umami. Hardened with:
`whereNotNull('user_agent')` skips rows we never captured a UA on,
`Number()` coercion on COUNT results (pg returns strings), and a
guard against divide-by-zero when access_logs is empty.

## API key handling

Mirrors the existing recaptcha-secret pattern: stored plaintext in
`app_settings`, masked as `••••••••` on every GET via the existing
`adminSettings.js` GET handlers, and the frontend save mutation
silently drops the masked sentinel so re-saving without typing a
new key preserves the stored value.

## End-to-end smoke (dockerised backend with my fixes applied)

```
chartData total views: 27   ← previously 0 (date merge broken on PG)
totals: {'views': '27', 'downloads': '3', 'uniqueVisitors': '1'}
devices: {'desktop': 100, 'mobile': 0, 'tablet': 0}   ← was 0/0/0
devicesSource: access_logs   ← falls back correctly
analytics_umami_api_key (GET /settings/analytics): ••••••••
```

## Tests

**Backend** (15 new cases):
- `umamiClient.test.js` (10): missing-config → null, URL shape +
  `x-umami-api-key` header, websiteId URL-encoding, `{x,y}` →
  percentages, `laptop` → `desktop` mapping, unknown buckets
  dropped, empty payload → null, non-2xx → null, invalid JSON →
  null, network error → null.
- `analyticsDateMerge.test.js` (5): YYYY-MM-DD string pass-through,
  ISO timestamp slice, JS Date (pg shape) → YYYY-MM-DD, null/empty
  → null, coercion for unexpected types.

**Frontend**: full 84-case vitest suite still green (no analytics
unit tests existed before; not adding any here — the changes are
narrow and the unit-level confidence comes from the type system +
the backend smoke above).

Closes #661 (bugs A + B + C). Rybbit / pluggable tracker support is
the next conversation per the issue author's follow-up.
2026-06-23 17:37:41 +02:00
Luca 182e655fcf feat(workflows): invoice prepared+approved early, dispatch waits; daysBefore in editor; dashboard approvals
- Booking built-ins reordered: prepare the invoice EARLY (admin adjusts line
  items), admin approves at the review gate whenever, then the wait holds
  dispatch until the event date and it sends itself. prepInvoice → reviewInvoice
  → waitEvent → sendInvoice (both booking_full and booking_simple; v3).
- Flow editor now reads/edits/saves trigger_config; the pre-event "days before
  event" lead time is editable in the canvas toolbar (was only in settings,
  which the cutover removed — closing that gap).
- Dashboard: pending-approvals card under "Events Expiring Soon" (workflows flag
  + non-empty only), with inline Confirm/Deny.

Confirms the design: a gate's confirm edge can feed a wait, so an admin OK
before the event parks the run at the wait and the scheduler dispatches on the
date. New test covers confirm-early-then-wait-dispatches.
2026-06-23 16:11:18 +02:00
Luca 0b6c33e59a feat(workflows): hard cutover of gallery-expiry + dunning + pre-event to flows
Seed gallery_expiring / gallery_expired built-ins and make the live automations
flow-owned, with zero feature loss:

- New delegating actions (notify_gallery_expiring / notify_gallery_expired /
  notify_pre_event) call the EXISTING send functions, so the engine path is
  byte-identical to the legacy hourly checker/pass (same templates, recipients,
  variables, dedup, per-event overrides, sent_at idempotency).
- Cutover built-ins (invoice_dunning, gallery_expiring, gallery_expired,
  pre_event_email) now ship ENABLED; booking flows stay disabled (stubs).
- The legacy paths stand down via existence-based isBuiltinFlowPresent guards:
  once a built-in is seeded (flag on) the engine is the single switch — flow
  enabled = it sends, flow disabled = off — so no double-send and reminders/
  expiry emails can still be fully turned off.
- emitDueEventReminders now honours the per-event reminder controls
  (disabled / offset / sent_at) so pre-event timing is faithful; fixed a
  Number(null)===0 offset bug.

Settings UI cutover is gated on the `workflows` flag (default off): when the
engine is live, the dunning reminder schedule (CRM settings) and the pre-event
global toggle (Reminder emails) are replaced with a "now in Workflows" callout;
when it's off, the legacy controls stay so flag-off installs lose nothing. The
late-fee math and installment-trigger defaults stay (fee math / scheduler-owned).

Split/installment invoices intentionally remain scheduler-driven (no flow).
2026-06-23 15:58:19 +02:00
Luca fa7b1bae95 feat(workflows): admin review gates before sends + migrate lifecycle/time triggers
Booking built-ins now gate every outbound document on an explicit admin OK:
prepare_* drafts the doc, the admin adjusts line items/terms, confirms the
"Review … before sending" gate, and only then does send_document fire. Added to
booking_full (contract + invoice) and booking_simple (invoice); seed versions
bumped so the disabled built-ins self-heal.

Migrated the remaining time- and event-driven triggers into the engine, all
additive / best-effort / fail-closed (no behaviour change when the flag is off):
- gallery.published (event creation)
- gallery.expiring + gallery.expired (expiration checker, alongside the email)
- quote.sent (was queued but never emitted — gap closed)
- contract.sent + contract.signed (sent, fully-signed via counter-sign or wet upload)
- customer.created (direct add + invitation accept)
- invoice.overdue (status→overdue flip, deduped per invoice)

Editor trigger list extended to match. Tests assert the review gates wire
confirm→send on both booking flows.
2026-06-23 14:58:48 +02:00
Luca b38c216f22 test(workflows): raise beforeAll timeout for migration-heavy CRM suites
The workflow/dunning suites boot the full core-migration set in beforeAll via
bootCrmDb. In isolation that's ~1.3s, but under full-suite parallel load on a
small CI runner it can exceed Jest's 5s default, timing out beforeAll and
failing every test in the file (the CI flake). Match the existing pattern used
by the other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill)
and set jest.setTimeout(30000) on workflowEngine, workflowRoutes and
invoiceDunning.
2026-06-23 14:40:07 +02:00
Luca 62ba905464 feat(workflows): seed booking + pre-event built-ins, wire event.date_approaching
Three more editable built-in flows, seeded disabled like the dunning ladder:
- booking_full: quote.accepted → prepare/send contract → admin "signed?" gate
  → create event → wait to event date → prepare/send invoice
- booking_simple: the no-contract path (quote.accepted → event → invoice)
- pre_event_email: customer reminder + admin heads-up, fired daysBefore the
  event date

The booking document actions stay stubs (observable skipped steps) until the
booking cutover. pre_event_email uses the already-wired send_email action, so
it is functional once enabled — backed by a new scheduler emitter
(emitDueEventReminders) that fires event.date_approaching for events entering a
flow's lead window, deduped per event. Refactors the boot seeder to a built-in
registry so each flow self-heals on its own SEED_VERSION.
2026-06-23 14:11:11 +02:00
Luca e70ddd36b8 feat(workflows): test-fire — safe dry-run of any flow on demand
Engine testRun() walks the whole graph immediately: waits pass through,
gates auto-confirm, side-effecting actions short-circuit to {dryRun, would}
so no real emails go out. POST /admin/workflows/:id/test-run returns the
run status + per-node step log. Admin list gets a flask button that opens
a result modal with an optional entity id (e.g. invoice) for conditions.
2026-06-23 13:57:02 +02:00
Luca 192d2cbc06 feat(workflows): crash recovery — resume runs orphaned mid-flow
Closes the crash-safety gap: a run left in running/pending by a crash had
nothing to resume it (the scheduler only wakes 'waiting'). Adds a heartbeat
(workflow_runs.updated_at, stamped on every node advance + start/resume) and a
recoverStaleRuns() sweep that re-enters runs whose heartbeat has gone stale
(>10 min) from their persisted node. Runs on the scheduler tick AND the boot
tick, so a restart catches anything stranded during downtime.

Re-entry is at-least-once (the current node may re-execute) — loop counters +
the late-fee math are idempotent, so the only residual risk is a duplicate
reminder email. An attempts counter (migration 145, cap 5) marks a run failed
instead of recovering a node that reliably crashes the process (crash-loop
backstop). Flag-gated. Tests: orphan-resume + crash-loop cap.
2026-06-23 13:39:17 +02:00
Luca 83dc95a62b test(crm): dunning fee math, VAT-toggle gating + invoice immutability
Covers the tax-sensitive bits the dunning rework added (previously untested):
flat vs percent fee, the VAT toggle applying the org rate AND no-op'ing when
the org has no VAT rate, per-reminder accumulation (2nd=1x / 3rd=2x), the
invoice total staying immutable while the fee is tracked, and the 3-reminder
cap. Exports the fee resolvers + applyReminder for testing; PDF render stubbed
(flaky in CI, verified manually). 6/6 pass.
2026-06-23 13:30:48 +02:00
Luca 5ed2fec2fe feat(crm): Mahngebühr on a separate Mahnung document; invoice stays immutable
Corrected dunning model (Mara): a Mahnung is a reminder LETTER showing the new
total (original + Mahngebühr), NOT a separate invoice and NOT a mutation of the
issued invoice.

- The invoice PDF no longer shows the fee (buildInvoiceRenderContext reports
  lateFeeAmountMinor 0) and is NEVER re-rendered by a reminder — it stays
  immutable (§14/§11).
- applyReminder now: tracks the fee as dunning state on the row (gross
  late_fee_amount_minor + new late_fee_vat_minor for the VAT portion, migration
  144), renders a separate MAHNUNG PDF (pdfService 'mahnung' kind — reuses the
  invoice layout: same lines + Mahngebühr row + new total, 'Mahnung' title, no
  QR), stored under storage/business-docs/mahnung/, and attaches BOTH the
  unchanged original invoice + the Mahnung to the reminder email.
- Fee resolvers split into net + VAT-rate (toggle + org-rate gated); a gross
  wrapper feeds the payment-check preview. en + de PDF title.

Outstanding/collections still read late_fee_amount_minor (now dunning state).
P3 (tax-report/Banana booking of the Mahngebühr VAT) stays Treuhänder-gated.
Syntax + 17/17 workflow/invoice tests green.

NOTE: the Mahnung PDF render path isn't unit-tested (PDF rendering is flaky in
the test env) — eyeball on the dev box: fire a level-2 reminder, confirm the
Mahnung PDF shows the new total and the original invoice PDF is unchanged.
2026-06-23 13:08:19 +02:00
Luca eaceb7e71c feat(crm): toggle for VAT on late fees (jurisdiction-dependent)
Mahngebühr VAT differs by country (CH: liable; DE/AT: not), so it's now a
toggle (crm_invoices_late_fee_vat_enabled, seeded into migration 143 in place
since it isn't deployed yet — no compensation migration). When on, VAT is
added on top of the net fee at the org's default rate
(business_profile.vat_rate_default). Gated so it's a NO-OP when the org doesn't
charge VAT (default rate 0/unset) — i.e. enabling the toggle on a non-VAT org
adds nothing, as required. Settings UI: a self-documenting checkbox.

The fee is treated as net + VAT-on-top; the tax-report VAT breakdown for the
fee is part of the deferred dunning-document rework. tsc 0, build green,
9/9 workflow tests.
2026-06-23 12:41:44 +02:00
Luca b78bd979dc feat(workflows): collections-handoff block after dunning exhausts
New escalate_to_collections action: when the 3-reminder loop ends still
unpaid, consolidate ONE email to the admin — customer data, outstanding
(invoice + late fees − paid), and the invoice PDF attached — ready to forward
to an Inkasso agency / for Betreibung. Internal mail, sent immediately; does
NOT touch the invoice. New invoice_collections_handoff email template (en +
native de, seeded by the boot self-heal). Wired into the built-in dunning
flow: loop 'exit' → collections → end (seed v4, re-seeds the disabled
built-in). Selectable + labelled in the canvas editor. Tests 9/9, tsc 0,
build green.
2026-06-23 12:37:50 +02:00
Luca dcdbeb9cc5 feat(crm): 3-reminder dunning + flat/percent Mahngebühr on 2nd & 3rd + AGB notice
- Late fee can now be a FLAT amount OR a PERCENTAGE of the invoice gross
  (crm_invoices_late_fee_type/_percent, migration 143; defaults preserve the
  current flat behaviour).
- Fee is charged from the 2nd reminder onward and accumulates per fee-bearing
  reminder (2nd = 1×, 3rd = 2×), computed from the level so re-applying a level
  never stacks. New resolvePerReminderFeeMinor() shared by applyReminder + the
  payment-check fee preview.
- Reminder ladder extended to 3 levels (caps raised in sendReminder +
  recordPaymentCheckAction); the built-in dunning flow now loops 3× (seed v3,
  re-seeds the disabled built-in on boot).
- Settings UI: flat/percent toggle + percent field, and a prominent AGB
  callout — a late fee is only enforceable if the concrete amount is stated in
  the terms (Mara's wording), 'verify with your Treuhänder'. en + native de.

The fee math is examples-only / Treuhänder-verify; issued invoices stay
immutable (the fee is tracked in late_fee_amount_minor, not folded into the
original total). Tests 17/17, tsc 0, build green.
2026-06-23 12:26:32 +02:00
Luca 289568fd52 feat(workflows): advanced text mode — export/import the flow as JSON
A 'Text' toggle in the editor toolbar swaps the canvas for the whole flow as
pretty JSON ({name, trigger_type, enabled, nodes, edges}). Copy it to share or
hand to an LLM, or paste a flow and 'Load into editor' (validates parse + one
trigger; backend re-validates on Save). Imported nodes land at 0,0 — one
'Clean up layout' click arranges them. en + native de.
2026-06-23 12:09:18 +02:00
Luca 79607c597a feat(workflows): 'Clean up layout' auto-arrange button (dagre)
Adds a one-click tidy that re-lays the graph top-to-bottom with dagre
(@dagrejs/dagre) and fits the view — handles the loop-back cycle by breaking
it internally. en + de string.
2026-06-23 12:03:36 +02:00
Luca 1734aba39c fix(workflows): dark-mode canvas + readable nodes + structured config
Addresses editor UX feedback:
- Dark mode: pass React Flow's colorMode (admin isDark) so the zoom/lock
  controls, minimap and selection render dark instead of white-on-black.
- Readable nodes: show a human label derived from type+config (e.g. 'Invoice
  paid?', 'Send payment-check email', 'Repeat ≤ 2×', 'Wait until due date')
  instead of the raw node_key, and label each output handle on the node
  (yes/no, confirm/deny, loop/exit) so branching is self-explanatory.
- Structured config: replace the raw-JSON textarea with a per-node form
  (NodeConfigPanel) — dropdowns for action/condition/recipient/operator,
  typed wait/loop/gate fields, live-applied; an 'Advanced (JSON)' expander
  remains for anything the form doesn't cover.
- en + native de strings for all of it.

tsc 0 errors, build green.
2026-06-23 11:49:32 +02:00
Luca cede885b04 fix(workflows): Postgres-safe id capture on workflow inserts
On Postgres, knex .insert() without .returning() resolves to [], so ins[0]
was undefined → the child workflow_nodes inserts hit a NOT NULL violation and
the whole transaction rolled back. Result on PG: migration + tables present
but zero rows — the seeded dunning flow never persisted, and the 'New
workflow' button would 500. SQLite returns the row id, so the test harness
masked it.

Add .returning('id') and normalise the {id} (pg) vs bare-id (sqlite) shapes
(same pattern as the crmDb harness) in both the built-in seed and the admin
create route. Tests stay green on SQLite (17).
2026-06-23 11:33:14 +02:00
Luca 5259ee9705 feat(workflows): migrate the dunning ladder onto the engine (cutover)
Makes the built-in dunning flow a faithful replacement for the hardcoded
reminder ladder instead of a disabled representation:

- queue_payment_check action delegates to invoiceService.queuePaymentCheckEmail,
  so the proven confirm + reminder_level + Mahngebühr state machine
  (recordPaymentCheckAction) stays the single source of truth — the workflow
  only decides WHEN the payment-check email (the gate) fires.
- runScheduledTasks now SKIPS the hardcoded reminder batches when workflows is
  on AND the invoice_dunning built-in is enabled, so the two never double-send.
- The built-in graph is re-authored to the delegation model (wait→due, grace,
  loop: check-paid → payment-check → wait-gap), dropping the redundant gate +
  generic reminder emails. A SEED_VERSION re-seeds the disabled, never-activated
  built-in on boot but never touches an enabled/edited one.

Tests: delegation graph shape, re-seed-when-stale, enabled-protection (9 engine
+ 8 route = 17 passing).
2026-06-23 11:12:19 +02:00
Luca 88881d0428 i18n(workflows): native DE + EN strings for the workflow pages
Adds the workflows.* block (list, approvals inbox, canvas editor) to en.json
and de.json so the Workflows UI no longer renders English inline fallbacks
under a German UI. DE authored natively.
2026-06-23 11:12:19 +02:00
Luca 5c0396d0c1 feat(workflows): React Flow canvas editor + list + approvals UI
Adds the admin Workflows surface (top-level nav, gated by the workflows
flag + workflows.view): a list page (enable toggle, delete, new), a
pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react)
canvas editor — palette to add nodes, drag handle→handle to connect
(branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a
side-panel JSON config editor, and save (writes a new version). Routes +
sidebar entry + workflows.service. Build + tsc clean.

NOTE: the workflow page strings render via inline English fallbacks; DE
translations for the workflows.* block are still pending native review.
2026-06-23 02:51:40 +02:00
Luca 9b557efbf3 feat(workflows): seed invoice-dunning ladder as an editable built-in flow
Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due,
grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder
loop with re-check, final notice) keyed on builtin_key='invoice_dunning',
sized from the reminder_first/second_days settings. Seeded DISABLED and
is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler
ladder still runs) — enabling it pre-cutover would double-send, so the
engine cutover is a deliberate follow-up. Idempotent (preserves admin edits).
Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape
+ idempotency.
2026-06-23 02:44:05 +02:00
Luca 1a0d6de04d feat(workflows): admin CRUD + run-history + approvals-inbox API
GET/POST/PUT/PATCH/DELETE /api/admin/workflows with graph read/write (PUT
writes a fresh node/edge set under version+1 and bumps workflows.version so
in-flight runs keep their pinned version). Run-history (/:id/runs,
/runs/:runId/steps) and the pending-approval inbox (GET /approvals,
POST /approvals/:id/:action → actById) round it out. Gated by the workflows
flag + RBAC (view for reads, manage for writes); built-in flows refuse
delete; graph validated (exactly one trigger, unique keys, edges reference
known nodes). Route tests cover CRUD, validation, version bump, toggle,
inbox, and the 403 permission gate.
2026-06-23 02:40:29 +02:00
Luca b48d8c2eb8 feat(workflows): approval gates — email confirm/deny + token resume
gate_setup action creates a workflow_approvals row (single-use token stored
as SHA-256 hash) and emails the admin confirm/deny links immediately
(internal mail, no business-hours floor). actByToken / actById finalize the
approval and resume the run down the matching confirm/deny edge; both are
idempotent (a second click → 'already recorded') and respect expiry. Public
GET /api/public/workflow-approvals/:token/:action returns a small HTML
confirmation page (clickable from email, single-use so prefetch can't
double-act). listPending backs the webview inbox (wired in the CRUD phase).
Test covers gate→approval→email→token-confirm→resume + idempotency.
2026-06-23 02:37:20 +02:00
Luca cc0ba5347d feat(workflows): emit lifecycle events from invoice + quote services
Wires the workflow event bus into the hot paths, AFTER each commit:
- invoiceService.sendInvoice → invoice.sent (idempotent per invoice id, so
  overdue re-sends don't double-fire)
- invoiceService.markPaid → invoice.paid, only on the transition into paid
  (transaction result captured so the emit runs post-commit, never rolling
  back a recorded payment)
- quoteService.recordResponse / adminAcceptQuote / adminDeclineQuote →
  quote.accepted / quote.declined via a shared emitQuoteEvent helper that
  resolves the customer email for downstream send_email actions

All emits are best-effort and fail closed when the workflows flag is off.
Existing invoice/quote integration tests still green.
2026-06-23 02:33:14 +02:00
Luca 96fb44045e feat(workflows): data-touching action + condition handlers
Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business-
hours floor via queueEmail's respectBusinessHours) and the invoice_paid
condition (paid_at / status / cumulative paid_amount). Registers the
prepare_quote/contract/event/gallery/invoice + send_document + reserve_date
document actions as recognized-but-not-yet-wired (record an observable
skipped step rather than crashing a flow). index.js side-effect-imports the
handlers. Tests cover the customer-mail routing + the invoice_paid logic.
2026-06-23 02:28:02 +02:00
Luca 610a3dfd73 feat(workflows): scheduler resumes elapsed wait nodes
Adds engine.runDueWaits() — polls waiting runs whose wake_at has passed and
resumes the ones parked on a wait node (gate timeouts handled later by the
approvals layer). Flag-gated (fails closed when workflows is off). Wired into
the existing hourly invoiceScheduler tick in its own try/catch so a workflow
failure never suppresses the invoice/reminder jobs. Test covers not-due vs
elapsed resume.
2026-06-23 02:09:57 +02:00
Luca 1eaef67c36 feat(workflows): execution engine core + registry + tests
Graph executor that walks nodes/edges per run: trigger, condition/branch
(registered conditions → yes/no edge), bounded loop (counter in context +
maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate
(status=waiting; resumed via confirm/deny edge), action/webhook (registered
handlers). emitWorkflowEvent creates one idempotent run per matching enabled
workflow (unique dedup_key) and fails CLOSED if the flag system is
unavailable; never throws into callers (safe to call after commit). Every
node records a workflow_run_steps row. Registry seeds primitive
conditions (always/never/expr) + actions (noop/log/set_context). Integration
test covers loop+wait resume, gate confirm, and dedup.
2026-06-23 02:05:34 +02:00
Luca c818c25cf2 feat(workflows): schema + permissions (migration 142)
Adds the workflow engine's graph data model — workflows, workflow_nodes,
workflow_edges (versioned so in-flight runs keep their version),
workflow_runs (status/current_node/context, wake_at for the scheduler,
unique dedup_key for idempotency), workflow_run_steps (per-node audit),
and workflow_approvals (hashed email confirm/deny token + webview inbox).
Seeds workflows.view / workflows.manage and grants them to super_admin +
admin. Loose-FK integers per the whatsapp_queue/expenses convention;
idempotent hasTable guards + reversible down().
2026-06-23 01:57:06 +02:00
Luca ff478619b5 feat(workflows): add workflows feature flag + Features-tab toggle
New opt-in 'workflows' master flag (default off) across the backend
KNOWN_FLAGS/DEFAULT_FLAGS and the frontend FeatureKey union, context
defaults, and a new Automation section card in the Features tab. Gates
the upcoming Workflows admin area and the engine runtime. en/de i18n
added (DE native).
2026-06-23 01:53:03 +02:00
Paul Nothaft a1f68bc081 Merge pull request #659 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.69.0-beta.0
2026-06-22 22:55:35 +02:00
github-actions[bot] f9690cb568 chore(beta): release 3.69.0-beta.0 2026-06-22 20:51:07 +00:00
Paul Nothaft 3ac70177ef Merge pull request #658 from the-luap/feat/feedback-per-guest-limits-655
feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
2026-06-22 22:50:45 +02:00
Paul Nothaft 98e97e3cf2 fix(i18n): replace ASCII quote with U+201D in DE perGuestLimitsDesc
CI's frontend test job failed with "Failed to parse JSON file, invalid
JSON syntax found at position 163854" on de.json:3041. The German
description used „…" — the opening „ (U+201E) was correct, but the
closing was an ASCII " (U+0022) which the JSON parser treated as the
string terminator, leaving "-Abläufe..." as garbage outside the string.

Replace with the proper German closing quote " (U+201D). 84/84 vitest
suite now passes locally. End-to-end smoke against a dev backend with
migration 141 applied confirms the modal renders correctly on desktop
(centered card) + mobile (bottom slide-up) and the backend returns the
structured 403 on the 11th-click cap hit.

Also flagging adjacent: origin/beta has a pre-existing duplicate `Mail`
import in frontend/src/pages/admin/SettingsPage.tsx (lines 20 + 58 from
commit 69367b45) that breaks Vite dev's Babel parser but passes prod
esbuild — out of scope for this PR, separate fix needed.
2026-06-22 22:40:13 +02:00
Paul Nothaft f2814e4a4c feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
Reporter @Duecki1 wants to stop telling guests "pick only 5 photos" by
hand. Per-event cap, enforced server-side, with a clear popup when the
11th click would exceed the limit. Per-guest scope matches the "every
couple picks their top 10" mental model; per-gallery aggregate is
explicitly NOT in scope (creates weird "first 10 visitors use up all
slots" race conditions).

## Schema (migration 141)

Two nullable columns on `event_feedback_settings`:
  - `max_favorites_per_guest`
  - `max_likes_per_guest`

null / 0 = unlimited (preserves current behaviour for every existing
install — operator must opt in). Both shipped together because the
code path is identical; photographers can cap either, both, or neither.

## Backend

- `feedbackService.submitFeedback` cap check on the INSERT branch only.
  Toggle-off (un-favoriting) is always allowed, so a guest at 10/10
  can free a slot by un-clicking an existing favorite.
- New `countGuestFeedback(eventId, type, guestId, guestIdentifier)` —
  matches the exact same guest-key shape the existing duplicate-check
  uses (guest_id when present, fallback to guest_identifier in simple
  identity mode).
- Limit reduction grandfathers: admin lowering 20→10 keeps existing
  rows in place; new adds blocked until the guest removes some.
- Route layer (`galleryFeedback.js` POST) translates a `limit_reached`
  service-return into a structured 403 with `code:
  'FAVORITE_LIMIT_REACHED'` / `'LIKE_LIMIT_REACHED'`, `limit`, and
  `current_count`. Stable UI contract.
- `feedback-settings` GET exposes the caps so the gallery UI can
  optionally render a counter near the heart icon (UI extension TBD;
  the modal alone is the contract this PR commits to).
- `feedbackValidation`: range guard `0..10000`, null allowed,
  per-field error messages.

## Frontend — the popup

New `FeedbackLimitReachedModal` component renders via a `createPortal`
to `document.body` so it escapes any lightbox / sticky parent stacking
context and reliably sits above everything else.

Mobile-first responsive:
  - `items-end sm:items-center` — slides up from the bottom on phones
    (native action-sheet feel), centers on desktop (familiar modal).
  - `w-full sm:max-w-md` — full-width on phones, clamps to 420px on
    desktop.
  - `rounded-2xl sm:rounded-xl` — more rounded on phones for the
    sheet feel.
  - `pb-[env(safe-area-inset-bottom)]` — respects the iOS home indicator
    and Android gesture bar.
  - `z-[60]` — above the lightbox's z-50.

Title + body + "8 of 10 used" pill + "Got it" button. Backdrop click +
Escape both dismiss. Focus management lands on the OK button so
keyboard / screen-reader users can dismiss immediately.

New `useFeedbackLimitModal()` hook is the shared API: components
on every submit-feedback site wire `onError: (err) => handleError(err)`
and render `{limitModal}` in their JSX. Returns `true` from
`handleError` when the error is a structured cap-reached 403 (so the
caller can skip its generic error toast). PhotoFavorites + PhotoLikes
+ PhotoLightbox all wire through the hook — every favorite/like submit
path is covered, including the lightbox's three different submit
sites (guest mode, simple mode, post-identity-modal-confirm).

## Admin UI

`FeedbackSettings` card gets a new "Per-guest limits" section that
only renders when at least one of `allow_favorites` / `allow_likes` is
on. Two numeric inputs (0 / empty = unlimited) side-by-side on
desktop, stacked on mobile. Hint text covers the limit-reduction
grandfathering semantics so admins aren't surprised.

## i18n

EN + DE for:
  - Modal title + body (parameterized with `{{limit}}`)
  - Counter pill (parameterized with `{{current}}` / `{{limit}}`)
  - OK button label
  - Admin field labels + hints + section header + grandfathering note

## Tests

**Backend** (`__tests__/utils/feedbackPerGuestLimit.test.js`, 8 cases):
  - null cap → unlimited (back-compat)
  - 0 cap → unlimited (UI convenience)
  - cap=10: rows 1-10 succeed, 11 returns limit_reached
  - toggle-off frees a slot at the cap
  - limit reduction grandfathers existing rows
  - per-guest scope: guest A's cap doesn't affect guest B
  - favorite cap doesn't block likes (per-type)
  - like cap returns LIKE_LIMIT_REACHED-shaped payload

**Frontend** (`__tests__/useFeedbackLimitModal.test.ts`, 7 cases):
  - Non-axios errors → null
  - Non-403 axios errors → null
  - 403 with wrong code → null
  - FAVORITE_LIMIT_REACHED parsed
  - LIKE_LIMIT_REACHED parsed
  - Falls back to code-implied type when feedback_type missing
  - Missing numeric fields → 0 (not NaN)

All 15 pass. tsc --noEmit clean. eslint clean on changed files.

Closes #655.
2026-06-22 22:02:13 +02:00
Paul Nothaft e1111b3848 Merge pull request #657 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.68.1-beta.0
2026-06-22 21:42:27 +02:00
github-actions[bot] 0b72751808 chore(beta): release 3.68.1-beta.0 2026-06-22 19:38:43 +00:00
Paul Nothaft 6193ab7f6a Merge pull request #656 from the-luap/fix/gallery-password-instagram-iab-654
fix(gallery): unbreak password entry in Instagram in-app browser (#654)
2026-06-22 21:38:20 +02:00
Paul Nothaft f4b6b8941a fix(test): raise bootCrmDb beforeAll timeout on slideshow suites
CI runners hit Jest's default 5s `beforeAll` timeout on
slideshowPublic.test.js's bootCrmDb call (~5.4s observed vs ~2s local —
runner-to-runner I/O variance, not a regression). Same hook shape on
slideshowAdmin.test.js is one slow runner away from the same failure.
Raise both to 30s so this stops blocking unrelated PRs branched off beta.

Adjacent to #654 — not strictly part of that fix but the only blocker
between #656 and a green CI right now.
2026-06-22 21:32:12 +02:00
Paul Nothaft b1bfd4838e fix(gallery): unbreak password entry in Instagram in-app browser (#654)
Reporter @Duecki1 hit "Incorrect Password" on byte-correct input from
Instagram's iOS/Android IAB. Backend bcrypt compare is fine — the
frontend was handing it a mangled byte sequence because the password
Input lacked the autocaps/autocorrect/spellcheck/autocomplete defenses
Instagram's WKWebView keyboard bridge needs (the standard `type="password"`
WebKit defaults that suppress autocaps get overridden inside the IAB).

Three layers of defense:

1. **Explicit input attributes** on the gallery password field —
   `autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`,
   `autoComplete="current-password"`. Stops iOS autocaps turning
   `wedding2026` into `Wedding2026`, stops predictive-text rewrites,
   nudges password managers to autofill the right credential rather
   than the IAB's stale saved-password store.

2. **Silent `.trim()` on submit** — Android Instagram IAB's predictive
   keyboard often appends a trailing space when the user taps the
   submit button. Event-gallery passwords don't legitimately carry
   leading/trailing whitespace (they're set by photographers, usually
   generated short strings), so trimming here is safe.

3. **Instagram IAB detection banner** — `frontend/src/utils/inAppBrowser.ts`
   detects the `Instagram` UA tag and surfaces a one-time advisory at
   the top of the password card with the right platform-specific
   "Open in external browser" instructions (⋯ menu copy for iOS,
   ⋮ for Android). Self-rescue path for users who hit it before we
   can close every keyboard mangling vector.

Scope is strictly Instagram per #654. Facebook IAB (`FBAV`/`FBAN`)
behaves identically and would benefit, but expanding the matcher is
a separate scope decision — the detector + i18n shape leaves room for
it without further refactor.

EN + DE i18n for the banner; 8 vitest cases on `detectInAppBrowser`
(iOS / Android Instagram UAs, plain Safari / Chrome / desktop UAs,
case-insensitive match, word-boundary defense against substring
collisions, SSR-safety when `navigator` is undefined). Lint + tsc
clean; pre-push Playwright smoke still expected green.

Closes #654.
2026-06-22 21:09:17 +02:00
Paul Nothaft df41d149e5 Merge pull request #653 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.68.0-beta.0
2026-06-21 21:40:39 +02:00
github-actions[bot] b86d171a2a chore(beta): release 3.68.0-beta.0 2026-06-21 19:35:28 +00:00
Paul Nothaft 80e8ec5bc7 Merge pull request #650 from the-luap/fix/whatsapp-template-params-647-followup
feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
2026-06-21 21:35:09 +02:00
Paul Nothaft 2887db48d1 Merge pull request #652 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.67.1-beta.0
2026-06-21 21:34:05 +02:00
github-actions[bot] c978d4d0fd chore(beta): release 3.67.1-beta.0 2026-06-21 19:33:49 +00:00
Paul Nothaft cde028e919 Merge pull request #649 from the-luap/fix/branding-customcss-preset-drop-645
fix(branding+whatsapp): preserve customCss through preset switches (#645) + admin-pinned WhatsApp template language (#647)
2026-06-21 21:33:29 +02:00
Paul Nothaft 4f77c0cb5a Merge pull request #651 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.67.0-beta.0
2026-06-21 21:23:07 +02:00
github-actions[bot] 9fb6d5dbf9 chore(beta): release 3.67.0-beta.0 2026-06-21 19:20:01 +00:00
Paul Nothaft 4356393b44 Merge pull request #646 from Luca-Timo/feat/live-slideshow
feat: Live Slideshow ("Diashow") — fullscreen, auto-updating projector view for live events
2026-06-21 21:19:32 +02:00
Paul Nothaft 1f46a241d2 chore(whatsapp): renumber migration 138 → 140 (after PR #646's 138+139)
PR #646's review-round renumbered its slideshow migrations to 138 + 139
to slot in after PR #649's 137 (whatsapp_template_language). That now
collides with this PR's 138. Slide ours to 140 so all three land in
strict order: #649 (137) → #646 (138, 139) → this PR (140). Content
unchanged; pure rename + a one-line docstring tweak noting the slot.
2026-06-21 21:12:22 +02:00
Paul Nothaft 16055cdc41 feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.

This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.

## Schema (migration 138)

Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.

## Processor

- `parseTemplateParams(raw)` — defensive parser; falls back to the
  5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
  slots in the listed order, computed via a small switch on slot key.
  The password line still receives the locale-specific 🔒 label and
  the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
  the parsed array to `buildComponents` per message.

## Admin route

- GET surfaces `template_params` as the parsed array (default 5-slot
  when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
  before persisting, so the stored value is always the canonical
  sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
  admin's test message matches their configured slot shape — a
  reporter who configures 2 slots gets a 2-parameter test send, not
  the legacy 5-parameter payload.

## UI

- `WhatsAppTab` gets a checkbox + up/down list under the Template
  language field. Each slot shows its current `{{N}}` position when
  checked, an em-dash when unchecked. Live preview below the list:
  "Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
  human-readable names.

## Tests

- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
  covering: parseTemplateParams sanitization (unknown keys, duplicates,
  non-strings, malformed JSON, all-invalid fallback, pre-parsed array
  acceptance) and buildComponents shape (reporter's 2-slot case,
  reorder, empty list, locale-specific password label, password
  sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.

## Migration numbering

Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.

## Honest caveat

Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
2026-06-21 20:51:20 +02:00
Luca e6655f613b chore(slideshow): renumber migrations to 138/139 (after whatsapp #649's 137)
PR #649 takes migration 137 (add_whatsapp_template_language). Renumber the
slideshow migrations to slot in after it:
- 137_add_slideshow_share.js  -> 138_add_slideshow_share.js
- 138_add_slideshow_styling.js -> 139_add_slideshow_styling.js
and update the slideshow migration-number references in comments/types. No
content change — both are additive + addColumnIfNotExists-guarded, so re-running
under the new filename on an already-migrated DB is a safe no-op.
2026-06-21 02:59:19 +02:00
Luca a995131f42 perf(slideshow): cache global settings to cut /state DB reads (PR #646 review)
Each /state poll fired ~10 getAppSetting reads to resolve the watermark/fit;
a leaked link x N tabs amplified that linearly (review concern 2). Add a
5s-TTL cached bundle (utils/slideshowGlobals) for the global slideshow_* +
branding-logo settings, invalidated on PUT /admin/settings/slideshow so admin
live-edit stays instant. slideshowSettings now does ~2 reads per poll (event
row + photo count) on a cache hit. Also documents the frontend
optimistic-default nit.
2026-06-21 02:53:34 +02:00
Luca e36b3309ca fix(slideshow): deny display-only token on download/upload/feedback (PR #646 review)
The slideshow JWT reuses type:'gallery', so verifyGalleryAccess accepts it on
every gallery route — a leaked projector link could download (single/all/
selected), upload (when allow_user_uploads), or post feedback for up to ~12h,
beyond its display-only contract. Add a `denySlideshowToken` middleware (403
when req.accessLevel==='slideshow') after verifyGalleryAccess on those 5 routes.
The photo-display routes (/photos, photo/thumbnail/preview/hero) stay open — the
kiosk needs them. +4 tests mint a real slideshow JWT and assert 403. Docs note
that Regenerate/Disable isn't instant revocation (~12h) and the feature flag is
the hard cut-off.
2026-06-21 02:46:29 +02:00
Paul Nothaft 4fd7709596 fix(whatsapp): admin-pinned template language + Arabic locale support (#647)
Reporter @Rekoo-PS hit three independent gaps trying to deliver an
Arabic Meta template. Bundled here because they fan out from the same
root cause (no first-class language config on the WhatsApp tab) and the
review surfaces are tightly coupled.

**1. Test send hardcoded `en_US` (`adminWhatsapp.js:141`).** Smoking gun
for "I can't make it work" — Meta returned template_not_found_in_language
(132001) on every test send for non-English templates, no matter what
else the admin configured. Replaced with `config.template_language ||
'en_US'`.

**2. No `template_language` field on `whatsapp_configs`.** The only
priors were per-message `data.language` (always null from our callers in
`adminEvents.js:854,1188`) and `app_settings.general_default_language`
(the *system UI* language, not the *template's* language registered with
Meta). Migration 137 adds the column; GET + PUT surface it; the
processor uses it as the highest-priority default when message_data
doesn't override.

Resolution order in `whatsappProcessor.processWhatsAppQueue` is now:
  1. message_data.language (per-event override — caller path TBD)
  2. config.template_language (admin-pinned template language)
  3. app_settings.general_default_language (system fallback)
  4. en_US (hardcoded last resort)

**3. `LANGUAGE_MAP` + `PASSWORD_LABELS` didn't cover Arabic.** Added
`ar` (Meta's single-code form per RFC; no region variant). For any
language we don't enumerate (e.g. Turkish `tr_TR`, Chinese `zh_CN`,
Hebrew `he_IL`), `resolveLanguageCode` now pass-throughs valid-shape
codes (lowercase-language + optional underscore + uppercase-region) and
forwards them to Meta as-is. If they don't match a registered template
Meta returns 132001, which the test route already surfaces back to the
admin via `error.message` — fail-loud, no silent fallback.

Validation:
- Unit smoke on `resolveLanguageCode` across 18 representative inputs
  (in-map, pass-through, canonicalization, rejection) — all behaviours
  correct.
- Lint clean on all 7 changed files.
- Frontend `tsc --noEmit` clean.
- Migration `node -c` syntax-checked; additive + `hasColumn`-guarded so
  re-running is safe.

Frontend: free-text input on the WhatsApp tab with EN + DE i18n.
Pointing at Meta's supported-languages docs via the hint text — Meta's
list grows; a hardcoded dropdown would rot.

Closes #647.
2026-06-20 23:29:08 +02:00
Paul Nothaft 7cf26795ec fix(branding): preserve customCss through preset switches + theme changes (#645)
Reporter @aemisrogers nailed the root cause: same #317 class of bug as
logoUrl. None of `GALLERY_THEME_PRESETS` (`theme.types.ts:125`) include
`customCss` in their `config` object, so any path that REPLACES
`currentTheme` with `preset.config` (or with a sparse `newTheme` that
came from `preset.config` upstream) silently dropped `customCss` from
React state. The persisted value in `theme_config` stayed correct (the
public gallery still rendered it), but the admin textarea showed
empty on reload — admin-UI display drift, not data loss.

Three surgical fixes, mirroring the #317 logoUrl pattern:

1. `BrandingPage.tsx` `handleThemeChange` — `customCss: newTheme.customCss
   ?? currentTheme.customCss` alongside the existing `logoUrl` fallback.
   Closes the propagation hole where the customizer's `handlePresetSelect`
   fires `onChange(preset.config)` (no customCss) and the parent wipes
   it from currentTheme.

2. `BrandingPage.tsx` `handlePresetChange` — preserve `customCss` from
   prev/currentTheme on preset switch, same shape as the existing
   `logoUrl: prev.logoUrl` preservation. Touches both the `setCurrentTheme`
   and the preview-mode `setTheme` paths.

3. `ThemeCustomizerEnhanced.tsx` `handlePresetSelect` — remove the
   `setCustomCss('')` that wiped the local textarea state on preset
   pick. The previous comment ("Clear custom CSS when selecting a preset")
   described the original intent but produced data drift across the
   preset round-trip. The sibling `ThemeCustomizer.tsx` already never
   cleared it; this aligns the two.

Verified against `v3.44.0` and `origin/beta`: identical code on both
branches, so the bug exists on stable + beta. Lint + tsc clean on the
two changed files.

Closes #645.
2026-06-20 23:17:34 +02:00
Luca 9dd353744e Update live-slideshow.md by removing metadata
Removed metadata section from live-slideshow documentation.
2026-06-20 13:55:39 +02:00
Luca 16013d1cf9 docs(slideshow): add Live Slideshow guide + README entries
- docs/live-slideshow.md: full feature guide (enable, generate link, run on a
  projector, global Settings -> Slideshow defaults, per-event overrides, how
  live updates work, security notes).
- README: Live Slideshow bullet under Key Features, a Live Events use case, and
  a Documentation quick link.
2026-06-20 13:30:44 +02:00
Luca 9fe9bd77fa test(slideshow): backend route tests for public + admin endpoints
25 tests over two files, using the integration test-DB helper (real sqlite,
all migrations):

- slideshowPublic: resolveSlideshow guards (feature-flag kill-switch -> 404,
  unknown/null token, expired/draft/archived), the watermark cascade (global
  look + per-event on/off + source->URL resolution + "null when no logo"),
  image fit, and /session minting (token + cookie). Regression-guards the
  app_settings reads (vs the nonexistent `settings` table bug).
- slideshowAdmin: generate/disable/regenerate, PATCH display + watermark mode,
  feature-flag 403, no-token 401, and PUT /admin/settings/slideshow validation
  + clamping. Both generate and PATCH assert success despite events having no
  `updated_at` column (the original 500).
2026-06-20 12:36:26 +02:00
Luca 5f1f4c2b3d refactor(slideshow): replace per-event-type preset with a picpeak-wide one
The slideshow display preset (transition / interval / speed / color filter) was
set PER EVENT TYPE in the Edit Event Type dialog. Replace it with a single
picpeak-wide default in Settings -> Slideshow ("Default style for new
slideshows"). New events seed their show_* columns from this global preset
(was: from the event type's slideshow_preset); the per-event override is
unchanged.

- Removed event_types.slideshow_preset usage everywhere (EventTypeModal section,
  eventTypes.service types, eventTypeService whitelist, adminEventTypes
  validators/POST). The DB column from migration 138 is left inert.
- Global preset stored in app_settings (slideshow_interval_ms/transition/
  transition_ms/colorfilter), saved via PUT /admin/settings/slideshow.
- adminEvents create-seeding now reads the global preset (getAppSetting) instead
  of the event type.
- en/de: presetTitle + presetHint.
2026-06-20 12:20:48 +02:00
Luca b5c73e05bd feat(slideshow): add image fit setting (fill vs black bars)
object-fit was hardcoded to 'cover', which crops portrait photos heavily. Add a
global `slideshow_fit` setting (Settings -> Slideshow): 'cover' fills + crops,
'contain' shows the whole image with black bars (no crop). Default 'cover'
(unchanged). Stored in app_settings (no migration), resolved server-side into
the slideshow settings + /state poll so a running projector picks it up live.
2026-06-20 12:06:34 +02:00
Luca 759784a4d1 fix(slideshow): feature flag is a master kill-switch, not just admin UI
Disabling the `slideshow` feature previously only hid the admin UI — the public
/show/:token route ignored the flag, so already-minted links kept working. Gate
resolveSlideshow on isFeatureEnabled('slideshow') so every /session and /state
404s when the feature is off: clicking Start shows "link not active" and a
running projector stops within one /state poll. Belt-and-braces: also gate the
admin generate + settings PATCH endpoints with requireFeatureFlag so links can't
be minted/changed while off (disable stays open so stale tokens can be cleared).
2026-06-20 11:59:46 +02:00
Luca 0166dc9658 refactor(slideshow): watermark look lives only in global settings (+ size)
The watermark look (logo / position / opacity / style) was configurable in three
places — the global Settings tab, the per-event-type preset, and the per-event
card. Consolidate it to ONE: the global Settings -> Slideshow tab. Per-event and
per-event-type now carry only the watermark MODE (inherit / on / off) — the
override structure — and render with the global look.

- New global "Size (% of screen)" control (slideshow_watermark_size, vmin-based)
  so the logo can be scaled; resolved server-side into the watermark payload and
  applied to the kiosk <img>.
- Backend slideshowSettings resolves the whole look from app_settings always;
  per-event show_watermark only toggles enabled. adminEvents PATCH + type-preset
  seeding no longer accept/seed per-event look fields; unused enums removed.
- Frontend SlideshowStyle drops the look fields (mode only); SlideshowStyleFields
  watermark section is a single mode select with a "configured under Settings"
  hint; SlideshowSettingsCard + Event type cleaned up.
- en/de: watermarkSizeLabel + watermarkModeHint.

(events.show_watermark_{source,position,opacity,style} columns from migration
138 are left in place but inert — the look is global now.)
2026-06-20 10:46:27 +02:00
Luca 69367b45be feat(slideshow): gate behind a feature flag + move globals to a Settings tab
- New `slideshow` feature flag (backend KNOWN_FLAGS/DEFAULT_FLAGS, frontend
  FeatureKey + context default, a toggle card under Settings -> Features -> Core).
  Default off; strictly opt-in.
- Move the global watermark defaults off the Event Types page into a dedicated
  Settings -> Slideshow tab (new SlideshowSettingsPage), shown only when the flag
  is on.
- Gate the per-event Live Slideshow card and the per-event-type preset section
  behind the flag too (and stop writing a type preset when it's off).
- en/de strings for the feature card + settings tab.
2026-06-20 03:08:04 +02:00
Luca db8388c79e fix(slideshow): dip-to-white/black no longer flickers the image
The flash overlay had no base opacity and the keyframe animation has fill-mode
none, so after the first dip it reverted to opacity 1 and stayed opaque between
slides — hiding the image, then briefly revealing it on each advance. Set base
opacity 0, and swap the image at the flash peak so the cut stays hidden.
2026-06-20 03:07:49 +02:00
Luca 6ec46de0e7 fix(slideshow): fill the viewport instead of black bars
The slide <img> used maxWidth/maxHeight:100% with no width/height, so it
rendered at the photo's intrinsic size (e.g. the 1920px preview) and never
scaled up to the projector, leaving black bars all around. Pin the image to
100% x 100% and use object-fit: cover so it fills the whole page.
2026-06-20 02:33:26 +02:00
Luca 0f4388d68a fix(slideshow): read globals from app_settings, not the missing settings table
slideshowSettings used settingsService.getSetting, which queries db('settings')
- a table that does not exist in this app (globals live in app_settings). Every
GET /gallery/:slug/show/:token/session and /state therefore threw and returned
500 INTERNAL_ERROR once a valid token resolved. Switch to getAppSetting
(utils/appSettings), which reads app_settings where the slideshow_watermark_*
and branding_* values are actually written.
2026-06-20 02:18:16 +02:00
Luca 056f9381de fix(slideshow): surface backend error in the live slideshow card
Log the failing request (status + body) to the console and show the backend
error message in the toast instead of a generic "Error", so failures are
diagnosable without server log access.
2026-06-20 01:49:30 +02:00
Luca 1e40f8296c fix(slideshow): drop updated_at from event writes
The events table has no updated_at column (only created_at, and no migration
adds one), so the slideshow generate/disable/settings endpoints 500'd with
'column "updated_at" does not exist'. Write only the show_* columns, and guard
the settings PATCH against an empty update.
2026-06-20 01:49:30 +02:00
Luca cb761ee621 feat(slideshow): en/de strings for live slideshow
Adds the slideshow.* block (transitions, color filters, watermark mode/style/
source, global defaults) and eventTypes.form.slideshowPreset labels in English
and German.
2026-06-19 22:11:17 +02:00
Luca 385b05adcf feat(slideshow): admin ui for live slideshow
- per-event Live Slideshow card on the event detail page: generate/copy/
  regenerate/disable the share link + live style (transition, timing, color
  filter, watermark).
- shared SlideshowStyleFields, reused by the per-event card and the per-event-
  type preset section in the Edit Event Type modal.
- global watermark default card on the Event Types page (Settings -> slideshow).
- WatermarkSourcePicker: visible logo tiles with previews (light logo / dark-mode
  logo / favicon / event logo) instead of a blind dropdown.
- watermark mode tri-state (inherit/on/off) + white-vs-original style.
- supporting service methods + Event/EventType types.
2026-06-19 22:11:12 +02:00
Luca fd02254f78 feat(slideshow): public fullscreen slideshow viewer
- /gallery/:slug/show/:token route + SlideshowPage: splash -> fullscreen kiosk,
  crossfade/cut/slide/kenburns/dip-to-white/dip-to-black transitions, color
  filters, white/original logo watermark overlay, contain/letterbox, cursor
  auto-hide, quiet-append of new uploads, live settings poll, and decode-ahead
  preload (first slide decoded before playback) so transitions do not struggle.
- slideshow.service for session/state + shared style types.
2026-06-19 22:11:02 +02:00
Luca dea5e0f8a6 feat(slideshow): backend api for live slideshow
- public GET /gallery/:slug/show/:token/session (validates token, mints a
  slideshow-scoped gallery JWT + sets the per-slug cookie so <img> requests
  authorize) and /state (cheap settings + photo-count poll). Reuse /photos for
  the list; skip the view-log for slideshow access so the kiosk does not pollute
  visitor analytics.
- admin slideshow link generate/disable + live style PATCH on events.
- event-type slideshow_preset whitelisted in CRUD; create-event seeds the new
  event's show_* columns from the type preset.
- global watermark defaults via PUT /admin/settings/slideshow; watermark cascade
  (global default -> per-event override) resolving the light/dark/favicon/event
  logo url.
2026-06-19 22:10:56 +02:00
Luca 1029dd05bd feat(slideshow): db columns for live slideshow
- 137: events.show_share_token + show_interval_ms/transition/transition_ms
- 138: per-event watermark (tri-state, nullable=inherit global), source/
  position/opacity/style + color filter columns, and event_types.slideshow_preset
  JSON so new events inherit a per-type default. Opt-in, no backfill.
2026-06-19 22:10:48 +02:00
Paul Nothaft 38514ce60f Merge pull request #644 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.66.1-beta.0
2026-06-19 09:58:16 +02:00
github-actions[bot] 5fde5be0d4 chore(beta): release 3.66.1-beta.0 2026-06-19 07:57:49 +00:00
Paul Nothaft 6f40db8597 Merge pull request #643 from the-luap/fix/security-ssrf-bola-trivy-sweep
fix(security): close NAT64 SSRF + photo-export BOLA + sweep Trivy alerts (GHSA-wmjx-pc37-272r, GHSA-9v4w-jrhx-g5wr)
2026-06-19 09:57:28 +02:00
Paul Nothaft d705059d3c fix(deps): bump qs/brace-expansion overrides + add uuid override for node-cron
Code-scanning Trivy alerts on the open beta (PR #641). Of the 10 open
alerts, 6 are stale (lockfile already past the fix) or live in
floating-tag base images (`nginx:1.28-alpine`, `node:22-alpine`) which
auto-update on the next CI rebuild — no code change needed for those.

The 3 actually present in the current `backend/package-lock.json`:

- `qs 6.15.0 → 6.15.2` (CVE-2026-8723, alert #266). Bump override from
  `>=6.14.2` to `>=6.15.2`.
- `brace-expansion 5.0.5 → 5.0.6` (CVE-2026-45149, alert #264). Bump
  override from `>=5.0.5` to `>=5.0.6`.
- `uuid 8.3.2` transitively via `node-cron@3.0.3` (CVE-2026-41907,
  alert #265). Add top-level `uuid: ^11.1.1` override so node-cron's
  nested resolution collapses into our root uuid version. node-cron
  uses only `uuid.v4()` — API-stable across v8 → v11. Verified the
  scheduler still constructs tasks under the override.

Lockfile regenerated; net -9 lines (one fewer uuid copy).

Stale alerts that will close on next code-scan rebuild:
- #205 postcss (frontend lockfile already at 8.5.14)
- #221 i18next-http-backend (backend lockfile already at 3.0.6)

Auto-resolved on next image rebuild (no Dockerfile change — floating
tags):
- #267 nginx (frontend `nginx:1.28-alpine`)
- #223 ip-address, #156/#155 picomatch, #140 brace-expansion (all in
  the npm CLI shipped inside `node:22-alpine`)

Refs: code-scanning alerts #264, #265, #266
2026-06-19 09:46:19 +02:00
Paul Nothaft b8211e9944 fix(security): close BOLA on photo-export + NAT64 SSRF in URL guard
Two security advisories landed against the open #641 branch — bundling
both because they touch independent surfaces and PR #641 is the next
beta ship vehicle.

**GHSA-9v4w-jrhx-g5wr (BOLA on /admin/photo-export/:eventId/*)** —
the three /:eventId-scoped routes in `adminPhotoExport.js` (filtered,
filter-summary, export) ran `adminAuth + requirePermission(...)` but
not `requireEventOwnership`, so any non-super-admin admin/editor with
photos.view (or photos.download) could enumerate + export the photos
of events created by other admins — leaking `original_filename`,
which routinely encodes client identity. Sibling `adminPhotos.js`
applies the middleware on every :eventId route; this file was the
single drift. Reporter: Wernerina.

**GHSA-wmjx-pc37-272r (NAT64 SSRF in `isPrivateIPv6`)** — the old
implementation did naive string-prefix checks (`startsWith('fc')`,
`startsWith('fe80')`) and had zero coverage for NAT64
(`64:ff9b::/96` per RFC 6052, `64:ff9b:1::/48` per RFC 8215). On
instances with NAT64/DNS64 egress, a webhook URL like
`http://[64:ff9b:1::a9fe:a9fe]/` translated through the gateway and
reached 169.254.169.254 — exfiltrating cloud metadata (IAM creds)
into `webhook_deliveries.response_body`. Rewrote `isPrivateIPv6` to
expand the address to its canonical 8-group form, block both NAT64
prefixes, decode embedded IPv4 from IPv4-mapped (`::ffff:0:0/96`) and
deprecated IPv4-compatible (`::/96`) forms and re-check via
`isPrivateIPv4`, and fail closed on any parse failure. Reporter:
tonghuaroot.

Added 34 unit tests covering: both NAT64 prefixes in hex + mixed
dotted-quad notation, IPv4-mapped IPv6 hex + mixed, deprecated
::IPv4 form, legacy fc00::/fd00::/fe80::/::1/:: cases stay blocked,
and public IPv6 (Google/Cloudflare/Google IPv6) negative controls
stay allowed.

Refs: GHSA-9v4w-jrhx-g5wr, GHSA-wmjx-pc37-272r
2026-06-19 09:46:19 +02:00
Paul Nothaft 0d02a0124e Merge pull request #642 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.66.0-beta.0
2026-06-19 09:45:01 +02:00
github-actions[bot] 0a91f1042f chore(beta): release 3.66.0-beta.0 2026-06-19 06:47:36 +00:00
Paul Nothaft 970e18ff33 Merge pull request #641 from the-luap/fix/backup-restore-large-archives
fix+feat: backups + per-category downloads + ConfirmDialog + WhatsApp + feedback-pivot (#640 A+B+C+D+E)
2026-06-19 08:47:15 +02:00
Paul Nothaft a8bb7b439f fix(i18n): wrap WhatsApp token show/hide aria-label through t()
i18n audit caught one straggler — the eye-icon toggle on the access-token
input had a bare `aria-label={showToken ? 'Hide' : 'Show'}` that wouldn't
translate for screen readers on non-English locales. Switched to
`t('common.hide')` / `t('common.show')`; added the matching `common.show`
key in EN + DE (common.hide already existed).

The two remaining `placeholder=` literals in the WhatsApp tab are sample
ID strings (`123456789012345`, `gallery_ready`, `+49123456789`) — those
are identifier/value examples, not translatable English.

Other PR-touched UI surfaces passed the audit clean: 30 new i18n keys
across categories (5), settings.whatsapp (16), settings.features.whatsapp
(2), feedback (3), and the activity-log + bell entries (4) all exist in
both EN and DE.
2026-06-19 08:42:22 +02:00
Paul Nothaft 49bfb45332 fix(settings): hoist tab-visibility useEffect above isLoading early return
Surfaced while exercising Part D (WhatsApp) end-to-end. Navigating to
Settings → WhatsApp triggered React error #310 ("Rendered more hooks
than during the previous render"). Root cause is pre-existing: the
SettingsPage redirect-to-visible-tab `useEffect` lived AFTER the
`if (isLoading) return <Loading />` early return, so on the
isLoading=true→false transition the hook count grew by one and React's
rules-of-hooks invariant blew up.

Move the effect above the early return so the hook count is stable
across renders. While here, switch the gating logic from "is the key in
the currently-visible nav list" (which the bundle couldn't reference
yet because the nav array is built lower down) to a small lookup keyed
by activeTab → matching dependency flag. That's an equivalent decision
for the four tabs we already gated (crm, contracts, reminderTemplates,
accounting) plus the new whatsapp tab.

Add `flagsLoading` from the FeatureFlags context to the deps so the
snap-back only fires once the server's actual flag values have arrived.
Without this, the initial render with the placeholder DEFAULT_FLAGS
would falsely snap away from any tab whose flag is "on" on the server
but absent from the placeholder.

Also add `whatsapp: false` to `DEFAULT_FLAGS` in FeatureFlagsContext
(was missing — TypeScript should have caught the Record<FeatureKey,
boolean> violation but the build pipeline didn't surface it). Without
this, `flags.whatsapp` is undefined on the placeholder, which had
secondary effects on tab visibility and the snap-back logic.

Verified via Chrome DevTools: Settings → WhatsApp now loads cleanly
with all 5 form fields, the saved config values prefilled, the Save
button, and the Send-test card.
2026-06-18 23:55:47 +02:00
Paul Nothaft fabd67aecd feat(feedback): export shape toggle — per-action vs per-guest pivot (#640 part E)
Ports 8digit/picpeak@ed7943b as a TOGGLE rather than a replacement. The
current per-action shape (one row per favourite/like/rating/comment) stays
the default for backward compat with any external scripts consuming the
export; the new pivot shape (one row per (photo, guest_identifier) with
boolean is_favorited/is_liked + star_rating + comment) is opt-in via a
?shape=pivot query param and a dropdown in the admin feedback page.

Pivot wins for "which guests engaged with which photos" analysis in
Sheets / Excel pivot tables. Long wins for engagement timeline analysis
and re-importing into another tool. Different products, both valid.

### Backend

- `feedbackService.exportEventFeedbackPivoted(eventId)`: new method.
  LEFT-of-Map approach, pure JS pivot so PG / SQLite behave identically.
  Key is `(filename, guest_identifier)` — anonymous guests with no
  identifier get a synthetic per-row key so two anonymous comments on the
  same photo don't collapse. Comments: most recent wins (history dropped
  in exchange for "current state" semantics). Hidden-by-moderator rows
  excluded — the pivot represents what we want to surface, not the raw
  event log.
- `adminFeedback.js` export route: accepts `?shape=pivot|long` (default
  `long`). CSV filename now carries the shape (e.g.
  `feedback-pivot-{id}.csv`) so repeated exports don't overwrite.
- `convertToCSV` helper in `adminFeedback.js` gains the three escaping
  improvements that 8digit's commit also shipped: booleans → `yes`/`no`,
  null/undefined → empty, escape strings containing newlines (\n/\r) as
  well as commas/quotes. Comments with line breaks were silently breaking
  CSV row counts before this. Improvements are pure wins regardless of
  shape; archives' own `convertToCSV` copy left untouched (separate
  surface, no behaviour drift risk).

### Frontend

- `feedback.service.ts` `exportEventFeedback()` gains optional `shape`
  parameter, default 'long'.
- `EventFeedbackPage.tsx`: new shape dropdown next to the CSV / JSON
  buttons (defaults to 'long'). Selected shape flows through to the API
  request AND the downloaded filename.

### i18n

3 new EN + DE entries (`feedback.exportShapeLabel`,
`feedback.exportShapeLong`, `feedback.exportShapePivot`).

### Notes

- Pivot shape is **per-guest current state**, not history. A guest who
  rated a photo, then changed their mind and removed the rating, would
  show the final state in the pivot but BOTH actions in the long form.
  Acceptable trade-off: pivot users care about the snapshot, long users
  want the trail.
- `latest_at` column in pivot gives a "most recent activity" timestamp
  per row, useful for sorting/filtering recent engagement.

### Test plan

- [x] Backend syntax + TS check + lint clean (no new warnings; existing
      `catch (error)` warning was pre-existing)
- [ ] Manual: feedback page → select Per-guest (pivot) → Export CSV →
      verify one row per (filename, guest) with is_favorited='yes'/'no',
      latest_at column populated
- [ ] Manual: long shape default still produces the same per-action
      output as before (no regression for existing consumers)
- [ ] Manual: comment containing a newline → pivot CSV escapes correctly,
      row count matches data length + 1 header
- [ ] Manual: archive a published event with feedback → archive's
      `feedback_data.csv` still uses the long shape (archive surface
      unchanged on purpose)
2026-06-18 23:08:55 +02:00
Paul Nothaft 78c8e9d9f9 feat(whatsapp): WhatsApp Business API notification channel (#640 part D)
Ports filpgame/picpeak's WhatsApp integration with substantial adaptation
to fit our codebase patterns. Deliver the gallery-ready notification via
Meta Graph API in addition to (or instead of) email — useful where the
customer base expects WhatsApp by default. Strictly opt-in behind the new
`whatsapp` feature flag.

### Backend

- **Migration 136** (`whatsapp_configs` + `whatsapp_queue`). Loose-FK on
  `event_id` matching our `inbound_documents` / `expenses` pattern (NOT
  filpgame's hard FK — deleting an event shouldn't RESTRICT on stale queue
  rows). Composite index on `(status, retry_count, created_at)` covers the
  poll path.
- **`whatsappService.js`**: thin Meta Graph client. Meta API version bumped
  v19 → v20 (filpgame's v19 deprecates Q3 2026); configurable via
  `WHATSAPP_META_API_VERSION` env var. Timeout dropped 10s → 8s for
  processor budget. Errors surface the Meta `error.code` so the processor
  can tell retryable from permanent.
- **`whatsappProcessor.js`**: queue processor polling every 30s (configurable
  via `WHATSAPP_QUEUE_POLL_MS`), 10 messages per cycle, 3 retries before
  marking `failed`. Default language sourced from
  `app_settings.general_default_language` (matches our email-language
  resolution pattern); replaces filpgame's hardcoded `pt_BR` fallback.
  Falls back to `en_US` if nothing is configured. No-ops gracefully when
  the `whatsapp` flag is off, the config row is missing, or the access
  token isn't set.
- **`adminWhatsapp.js`**: three routes (GET/PUT config, POST test). Gated
  by `requireFeatureFlag('whatsapp')` so operators who haven't enabled it
  can't see the surface. Access token masked as `'********'` on GET;
  masked values silently preserve the stored token on PUT. Enabling with
  no Phone Number ID, template name, or token (and none stored) fails at
  the validator.
- **Two hook points** in `adminEvents.js`:
  - **Create-and-publish-in-one-step**: queues immediately after the
    `gallery_created` email when `!isDraft && customerPhone &&
    waConfig.enabled`. Password from `req.body` is still in scope.
  - **Publish-from-draft** (`POST /:id/publish`): queues with the password
    the admin re-typed via PR #627's `PublishGalleryDialog`. When no
    password was typed (legacy API consumers without dialog), passes empty
    string so the password line renders blank rather than leaking the
    `(set at creation)` sentinel.
- **`server.js`**: starts `whatsappQueueProcessor` at boot. Non-fatal if it
  fails to start (logged as warning).
- **`feature_flags`**: new `whatsapp` flag in `KNOWN_FLAGS` and
  `DEFAULT_FLAGS` (default false).

### Frontend

- **`featureFlags.service.ts`**: `'whatsapp'` added to `FeatureKey` union.
- **`FeaturesTab.tsx`**: WhatsApp card in the Communication section
  (between Incoming mail and Messaging). Smartphone icon, "new" status,
  sidebar-hidden (no sidebar entry — config lives under Settings).
- **`whatsapp.service.ts`** (new): typed client for the three admin routes.
- **`WhatsAppTab.tsx`** (new): Settings tab. Form for Phone Number ID,
  WABA ID, access token (masked toggle), template name, and enabled flag.
  Separate card below for a static test send. Token masking matches the
  server's `'********'` sentinel — admin can edit other fields without
  re-entering the token.
- **`SettingsPage.tsx`**: WhatsApp tab nav item gated on `flags.whatsapp`
  (so it shows only when the feature is enabled); render block wires
  `<WhatsAppTab />`.

### i18n

22 new EN + 22 new DE entries covering the Settings tab form, the
Features-tab card, plus `admin.activities.whatsapp_config_updated` +
`admin.notificationMessages.whatsappConfigUpdated` for the bell /
dashboard surfaces from PR #637.

### Deliberately NOT included

- filpgame's **password-encryption-at-rest** layer
  (`password_encrypted`/`password_iv`/`password_key_version` columns).
  Our publish-from-draft password recovery uses the admin re-type flow
  from #627 (PublishGalleryDialog) — no plaintext at rest.

### Setup notes for operators

1. Create a Meta Business Account + WhatsApp Business App.
2. Register a phone number and obtain `phone_number_id` + `waba_id`.
3. Create a system-user access token (long-lived recommended).
4. Submit a message template for approval. The default `gallery_ready`
   expects 5 body parameters: customer name, event name, gallery link,
   password line, expiry date.
5. Enable the `whatsapp` feature flag.
6. Enter credentials under Settings → WhatsApp, send a test, then enable
   delivery.

### Test plan

- [x] Backend `node -c` on all new/changed files clean
- [x] `tsc --noEmit` on frontend clean
- [x] Backend dev container restart picks up new files, /health OK
- [ ] Manual: enable `whatsapp` flag → Settings → WhatsApp tab appears
- [ ] Manual: save config with masked-only token (existing token preserved)
- [ ] Manual: enable=true without phone_number_id rejected at PUT
- [ ] Manual: enable=true without stored or new token rejected at PUT
- [ ] Manual: create-and-publish event with customer_phone → queue row
      inserts with message_type='gallery_created'
- [ ] Manual: publish-from-draft via PublishGalleryDialog with password →
      queue row uses the admin-typed password in the {{4}} line
- [ ] Manual: test send to a real phone with valid Meta config + approved
      template → Meta returns messages[0].id, toast shows the id
- [ ] Manual: bell renders "WhatsApp configuration updated" in DE when
      the config_updated activity fires (via PR #637 smart default)
2026-06-18 22:57:22 +02:00
Paul Nothaft a3fcb5bc9e feat(common): generic Promise-based ConfirmDialog primitive (#640 part C)
Ports 8digit/picpeak@88bfde1 — replaces `window.confirm()` with a styled,
themed, accessible in-app modal. Usage:

    const confirm = useConfirm();
    const ok = await confirm({
      title: 'Delete event?',
      message: 'This will permanently remove the gallery and all photos.',
      variant: 'danger',
      confirmLabel: 'Delete',
    });
    if (ok) doDelete();

Three variants: 'primary' (default, no icon), 'danger' (red AlertCircle +
red confirm button), 'warning' (amber AlertTriangle). Keyboard support:
Escape cancels, Enter confirms (unless focus is in an input/textarea/select
so an open form doesn't get hijacked), backdrop click cancels. Cancel button
is focused by default — a stray Enter cannot accidentally confirm a
destructive action.

Wraps at App.tsx level, inside GlobalThemeProvider so the modal respects
the theme tokens, above the toast container so a confirm appearing under a
toast still gets the click. Provider exports through components/common
alongside the rest of the shared primitives.

This PR only lands the primitive. Existing window.confirm() call-sites are
left untouched — sweeping them is follow-up work that can land in any
cadence (each sweep is one component, no architectural risk). Existing
structured-input flows (PublishGalleryDialog, DuplicateEventDialog,
PasswordResetModal, etc.) stay as-is — they collect data, not yes/no.

No new i18n entries — uses common.cancel / common.confirm / common.close
which already exist in EN + DE.

### Test plan

- [x] tsc --noEmit clean
- [x] eslint clean on changed files
- [ ] Manual: pick any existing window.confirm() site (e.g. EventDetailsPage
      delete button), swap to useConfirm(), verify the modal renders with
      theme tokens, Escape cancels, Enter confirms, backdrop click cancels,
      focus lands on Cancel
- [ ] Manual: variant='danger' renders red confirm button + AlertCircle icon
- [ ] Manual: open the dialog from inside another modal (e.g. a settings
      panel) — z-[9999] keeps the confirm on top of any other overlay
2026-06-18 22:36:40 +02:00
Paul Nothaft 820f4835f1 feat(categories): per-category download permissions (#640 part B)
Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.

Defaults to true so categories created before migration 135 keep working
without admin intervention.

Credit: 8digit/picpeak@928164b + @751ec75.

### Backend

- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
  on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
  - `GET /:slug/photos` returns `allow_downloads` per category AND
    `category_allow_downloads` per photo.
  - `GET /:slug/download/:photoId` returns 403 when the photo's category
    disables downloads.
  - `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
    `whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
    The null check covers pre-migration-135 rows during the upgrade window.
  - `POST /:slug/download-selected` same filter pattern.

### Frontend

- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
  argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
  optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
  Green DownloadCloud icon when downloads are on, plain Download icon when
  off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
  blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.

No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.

### Test plan

- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
      icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
      no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
      ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
      (filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
      → downloads still work (defaults true via fallback)
2026-06-18 22:29:35 +02:00
Paul Nothaft e4e79a0b3a fix(archives): stream-extract restore for >2 GiB + preserve original_filename via manifest (#640)
Two related backup-integrity fixes from 8digit's fork (issue #640 items
#3 + #4), bundled because they touch the same two files and ship better
together than apart.

### Stream-extract restore for >2 GiB archives

`adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP
into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap,
so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and
since the frontend `onError` toast is the generic "Something went wrong",
the cause stays invisible. Real-world wedding archives routinely cross
2 GiB; affected restores have likely been silent failures.

Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk
as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape:

```js
const zip = new StreamZip.async({ file: archivePath });
const entries = Object.values(await zip.entries());
await zip.extract(null, eventDir);
await zip.close();
```

Re-import logic (photos, categories, sizes) unchanged; only field rename
`entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6.

### Preserve `original_filename` via photos manifest

Archive → restore round-trip currently loses `original_filename` (the
post-#508 column tracking the camera-side name) because the gallery
filenames are renamed on upload and can't be derived from the extracted
files. This matters now that the Lightroom export (#623) depends on
`original_filename` — a restored event lost that signal.

- **`archiveService.js`**: writes `photos_manifest.json` into the archive
  containing per-photo `{filename, original_filename, type, uploaded_at,
  category_name}`. Non-fatal: a manifest write failure falls through to
  legacy behaviour (filename used as original_filename, same as before).
- **`adminArchives.js`**: reads the manifest on restore, builds a
  `Map<filename → manifest>`, and assigns
  `original_filename = manifest?.original_filename || filename`.
  Archives produced before this lands have no manifest — restore logs a
  one-shot notice and falls back to filename, preserving backward compat.

Credit: 8digit/picpeak@eb018aa.

### Deps

- Removed `adm-zip ^0.5.16`
- Added `node-stream-zip ^1.15.0`

### What's NOT in this PR

8digit's commit also fixed the production compose healthcheck (`curl`
isn't in our Alpine image); that's already been addressed upstream in
the meantime. The frontend `onError` swallow on the restore toast is a
separate small follow-up.

### Test plan

- [x] `node -c` on both files clean
- [x] `node-stream-zip` async API verified at load time
- [ ] Manual: archive a multi-GB event → restore → confirm photos
      re-import with original_filename preserved
- [ ] Manual: restore an archive produced before this lands → confirm
      fallback to filename works (no manifest path crashes)
- [ ] Manual: confirm the new photos_manifest.json is inside the
      generated archive (`unzip -l <archive>.zip | grep manifest`)
2026-06-18 22:18:54 +02:00
Paul Nothaft 8212a647c7 Merge pull request #639 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.65.1-beta.0
2026-06-18 21:31:39 +02:00
github-actions[bot] c07fb09fe2 chore(beta): release 3.65.1-beta.0 2026-06-18 19:31:14 +00:00
Paul Nothaft f17c654e14 Merge pull request #637 from the-luap/fix/i18n-activity-types-comprehensive
fix(i18n): sweep activity-type translations + Events / API Tokens / Webhooks settings tabs
2026-06-18 21:30:44 +02:00
Paul Nothaft eaed00fcca Merge remote-tracking branch 'origin/beta' into fix/i18n-activity-types-comprehensive
# Conflicts:
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/en.json
2026-06-18 21:28:18 +02:00
Paul Nothaft 1239ec1273 Merge pull request #638 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.65.0-beta.0
2026-06-18 21:24:51 +02:00
github-actions[bot] 0156dd4296 chore(beta): release 3.65.0-beta.0 2026-06-18 19:24:25 +00:00
Paul Nothaft b5279155ea Merge pull request #636 from Luca-Timo/feat/accounting-inbound-invoices
feat(accounting): incoming-invoice workflow v2 + VAT/financial settings consolidation
2026-06-18 21:23:51 +02:00
Paul Nothaft 997a41293e fix(i18n): sweep Events / API Tokens / Webhooks settings tabs
Continuing the activity-type i18n sweep from this PR: three settings
tabs still had hardcoded English strings (or referenced i18n keys that
didn't exist in either locale).

EventsTab (Settings → Event Creation):
- defaultFeedbackEnabled + defaultFeedbackEnabledHelp were referenced
  by the component but missing from both locales. The inline-default
  English text leaked through to German users.

ApiTokensTab (Settings → API Tokens):
- "Preview" table-header column was a bare string literal; now wraps
  through t('settings.apiTokens.preview').
- confirmRevoke called t() with a backtick template-literal default
  ("Revoke \"${token.name}\"…"). The interpolation happened at the
  default-string level, so the actual translated string never received
  the name and shipped without it. Switched to the i18next {{name}}
  parameter pattern with the matching value in en+de.

WebhooksTab (Settings → Webhooks):
- Half the tab was still hardcoded English. Wired everything through
  t(): toast messages (createError, updateError, deletedToast,
  deleteError, copied, copyFailed), Just-Created Secret card buttons
  (Copy, Dismiss), form placeholders (name, URL, template), advanced
  toggle label, filter and template help paragraphs, the filterError
  setter, all six table headers, the eventsSubscribed count (with
  proper {{count}} pluralisation), the status badge (Active/Disabled),
  the active/inactive title tooltips, the Deliveries link, the Delete
  button, and the delete-confirm dialog (proper {{name}} interpolation
  instead of the broken template-literal-in-default-string pattern).

Added 34 new key/value pairs to each locale; counts now symmetric at
events=28, apiTokens=23, webhooks=43 in both EN and DE.

DE wording authored natively; tone matches the existing maintainer-
voice style.
2026-06-18 21:16:30 +02:00
Paul Nothaft bc8d3330bb fix(i18n): sweep activity-type translations + smart notification fallback
The admin notification bell and dashboard "Recent Activities" panel were
showing raw snake_case keys ("event_published") or the generic
"Systemaktivität: <type>" fallback for ~65 activity types — most of them
from the CRM and Accounting modules added since #555. Users with German
locale saw the gap most visibly because the English placeholder leaked
through.

Three pieces:

1. notifications.service.ts — smart `default:` branch. Instead of falling
   straight to the systemActivity template, derive the camelCase i18n key
   from the snake_case type, try resolving `admin.notificationMessages.<camelCase>`
   directly with the full metadata spread as params, and only drop to the
   legacy template when no specific translation exists. This means every
   future activity type just needs an i18n entry — no per-type switch
   case to add.

2. en.json + de.json — added 65 missing `admin.notificationMessages.*`
   bell entries and 58 missing `admin.activities.*` dashboard entries
   across both locales. Covers Contracts (13), Quotes (7), Invoices /
   Storno (12), Monthly billing (5), Expenses (4), Hours (5), Incoming
   invoices (6), Customers (1), Admin user mgmt (3), and 9 misc /
   legacy types (bulk_archive_completed, email_resent, email_queue_flushed,
   email_template_created, event_duplicated, feedback_deleted,
   feedback_moderated, feedback_settings_updated, word_filter_added).
   Both locales finish symmetrical (149 activities / 136 notifications
   each, vs. 91 / 71 before).

3. admin.service.ts `formatActivityMessage` messages dict — added the
   same 58 English-only entries as a last-resort fallback for the
   dashboard when i18n itself fails to load. Keeps the surface
   resilient against bundle-load issues.

Metadata field names in the new translations match what the backend
writes via `logActivity()` — `{{contractNumber}}`, `{{quoteNumber}}`,
`{{invoiceNumber}}`, `{{username}}`, `{{template_key}}`,
`{{source_event_name}}`, `{{word}}` — verified against the call sites
in contractService, quoteService, invoiceService, userManagementService,
expenseService, adminEvents, adminEmail, adminFeedback.

DE wording authored natively; tone matches the existing terse,
maintainer-voice style of the rest of the file.
2026-06-18 21:10:12 +02:00
Luca e9b297c162 fix(crm): editor totals box computed VAT 100× too small
LineItemsTable's live preview did `Math.round(subtotal * vatRate) / 100` where
subtotal is in major units and vatRate is a fraction (0.081) — rounding to whole
units before the /100 divided the VAT by 100 (CHF 0.63 instead of 63.18). Add the
missing *100 inside the round so it rounds to cents. Backend computeTotals + the
PDF + the tax report were always correct; this preview-only bug just surfaced now
that new invoices seed a non-zero default VAT code instead of 0%.
2026-06-18 19:17:12 +02:00
Luca db9e41d198 fix(accounting): tax-report storno totals + hours-line date on Postgres
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface):

- taxReportService: income totals excluded only `status='cancelled'`, never
  `kind='storno'`. A Storno (status='sent', amounts stored negative) netted into
  the totals on top of the already-excluded cancelled original → double-subtract,
  so a cancel-and-reissue read as 0 income instead of the reissued amount.
  Now exclude storno rows from grandTotal*/byRate (kept visible in the row list).
  Regression test reproduces the real cancel→storno→reissue 3-row flow.
- customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)`
  on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the
  invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass).
  Normalise via the Date branch like every other date read.
2026-06-18 18:40:22 +02:00
Luca 707c5d0277 fix(accounting): address the-luap PR #636 review
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
  longer auto-classifies every supplier — incl. the admin's own domestic one —
  as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
  customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
  so they aren't billable to an anonymized account. (NB: picpeak has no hard
  customer delete — erase anonymizes in place — so the orphan/404 premise can't
  occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
  to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
  unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
  (the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
  real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
  /hourly + profileFields.title/savedToast).
2026-06-18 18:40:11 +02:00
Luca 33d5408977 refactor(accounting): consolidate the Accounting tab into two cards + one Save
- Box 1 "Default rates": mileage, daily allowance, hourly rate, require-proof.
  Hints now make the cost-vs-billing split explicit (daily allowance = expense,
  hourly = billing fallback).
- Box 2 retitled "VAT": registration, reclaim, default invoice VAT code, and
  the VAT label (moved out of its own card).
- Drop the third card (AccountingProfileFields deleted); the two Save buttons
  become one — it persists both the app_settings and the two business_profile
  fields (VAT label + hourly rate) together.
- Rename "Per-diem" → "Daily allowance" (EN) for clarity; German keeps the
  established "Spesenpauschale".
2026-06-18 15:56:52 +02:00
Luca 267b121d66 feat(accounting): supplier-country tax default + configurable default output VAT code
VAT supplier-country reclaim default:
- Migration 134 adds inbound_documents.supplier_country.
- categorizeInbound auto-derives tax_treatment via resolveTaxTreatment:
  explicit treatment wins; else country in the reclaim list → domestic,
  outside it → foreign_vat_non_reclaimable, unknown → domestic. Consumes the
  previously-stored-but-unused accounting_vat_reclaim_countries.
- Triage modal gains a Supplier country dropdown (saved via updateInbound).
  +5 unit tests for resolveTaxTreatment.

Configurable default output VAT code for new invoices:
- New accounting_default_output_vat_code setting (PUT wired; getSettings/type).
- Settings → Accounting dropdown to pick it.
- Invoice + quote editors seed their VAT picker (rate + code) from it on a
  blank new document — skipping edits/conversions, never clobbering a touched
  value. New docs no longer silently start at 0%.

i18n en + de.
2026-06-18 15:35:58 +02:00
Luca 348955b261 fix(hours): move logActivity out of the entry transactions (SQLite deadlock)
logActivity writes via the global db; called inside a db.transaction it
deadlocks against the held write lock on a SQLite-backed install (a second
write connection blocks). Stage the audit info inside each transaction and
fire it AFTER commit in createEntry / updateEntry / deleteEntry /
billUnbilledEntries — same fix already applied to expenseService. Return
shapes unchanged. (The monthly/billing paths still route through
createInvoice, whose own internal logActivity remains the shared root
limitation — tracked in feedback_sqlite_global_write_in_transaction.)
2026-06-18 15:35:48 +02:00
Luca 51837c3a88 feat(accounting): invoices force-enable the Accounting master
Invoice VAT config (codes + label) and the hourly rate now live under
Settings → Accounting, so an install with Invoices must have Accounting
available.

- applyDependencyRules (backend adminFeatureFlags.js + frontend
  FeatureFlagsContext.tsx): bills on → accounting on, before the
  accounting→children rule so the sub-features keep their own state.
- Migration 133 corrects existing installs: set the STORED accounting=true
  where bills is on. requireFeatureFlag('accounting') reads the raw row, so
  without this an upgraded install (invoices on, accounting off) would show
  the tab but 403 its endpoints. Idempotent; only flips on; no down.
- Features tab: the Accounting card shows locked-on (disabled + hint) while
  Invoices is enabled.

Also includes the i18n keys (en/de) for the VAT/financial settings move.
2026-06-18 15:11:11 +02:00
Luca dc7b87bb87 feat(accounting): consolidate VAT/financial config into Settings → Accounting
- Remove the orphaned "Default VAT rate %" from Business profile; the rates
  are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
  is now code-only — options are exactly the Accounting output codes, no
  free-text custom rate. Off-list legacy values on existing invoices are
  preserved as a read-only "(not configured)" option so issued documents
  aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
  AccountingProfileFields card; storage stays on business_profile, own save).
  Wire vat_label onto the PDF VAT-line label via the issuer block (covers
  invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
  dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
  preserved). Add a moved-note callout. Strip the moved fields from the
  Business-profile save so it can't clobber an Accounting-tab edit.
2026-06-18 15:10:44 +02:00
Luca 315d15afd4 test(accounting): incoming-invoice integration test + fix vat_code reload & SQLite logActivity deadlock
- Add backend/__tests__/integration/incomingInvoiceRebill.test.js (8 tests):
  disposition state machine, per-event PENDING pool, passthrough-no-markup,
  unwindBilledLine recompute, INVOICE_LOCKED on an issued invoice, and
  re-categorisation transitions. The invoice-MINTING paths can't run inside an
  outer transaction on SQLite (createInvoice's sequence claim deadlocks on the
  held write lock) — covered by buildInboundLineItem unit tests + discountLineItems
  instead; documented in the test.

- Move logActivity out of the categorize/rebill/bundle transactions. It writes
  via the global db; inside a transaction a second write connection deadlocks on
  a SQLite-backed install (also affected SQLite-prod, not just tests).

- Fix bill-editor vat_code reload: transformInvoice (adminInvoices.js) dropped
  vatCode, so the editor fell back to rate-matching and lost a custom-rate code
  on edit. Now returns vatCode: i.vat_code.

- Rewrite docs/accounting-inbound-invoices.md to the current implementation
  (IR-vs-Expenses split, re-categorise + unwind, cadence-aware re-bill / pending
  pool, passthrough-at-cost, migrations 122-132, rasterised preview, tax/ledger/VAT).
2026-06-18 14:11:08 +02:00
Luca 9a023c0197 feat(accounting): explain dispositions inline, drop markup from pass-through
- Add a per-disposition info line under the Disposition dropdown so re-bill
  vs pass-through vs company expense is clear in-context (en + de).
- Markup is a re-bill concept only: the control now renders solely for
  rebill, and a pass-through always bills at cost. Enforced server-side too
  (categorizeInbound applies markup only when disposition === 'rebill').
- Clarify "Book to" with a hint — it attributes the supplier cost to an
  event in the tax report / ledger export, separate from who you re-bill to.
2026-06-18 12:44:18 +02:00
Luca 36a8e42f90 feat(accounting): re-categorize incoming invoices, note field, pending re-bill pool
Address three incoming-invoice issues:

1. Re-categorization: a categorized invoice can now be changed again (e.g.
   passthrough → company expense). New "Re-categorize" button pre-fills the
   triage modal from the existing disposition/customer/markup/note.
   categorizeInbound is re-runnable — it unwinds any prior re-bill line
   (removes the invoice line + recomputes totals) before applying the new
   disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an
   already-issued invoice.

2. Note field: new `note` column (migration 132 — 126 is already on beta)
   captured in triage and shown in the read-only view.

3. Re-bill like hours: rebill/passthrough now persist customer_account_id.
   Per-event customers accumulate as PENDING items, surfaced in a new
   "Pending re-bills" card and bundled into one invoice via "Bill these"
   (mirrors unbilled-hours billing). Monthly/manual customers keep
   auto-consolidating onto their running draft. Passthrough (durchlaufend)
   can now also attach to a customer with optional markup.

Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and
en/de translations (other locales fall back to English defaults).
2026-06-18 12:23:13 +02:00
Paul Nothaft 9df2502547 Merge pull request #635 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.64.0-beta.0
2026-06-18 07:20:01 +02:00
github-actions[bot] f6ad6b71dd chore(beta): release 3.64.0-beta.0 2026-06-18 05:18:49 +00:00
Paul Nothaft fc5c1ae93f Merge pull request #633 from the-luap/feat/export-preview-modal-631
feat(admin/exports): inline preview modal with copy-to-clipboard (#631)
2026-06-18 07:18:27 +02:00
Paul Nothaft 27b5f7e4b6 feat(admin/exports): inline preview modal with copy-to-clipboard (#631)
Follow-up to #623. The Lightroom TXT export now shows the filename list
in a modal with a "Copy to clipboard" button instead of triggering a
.txt file download — saves the "open file → select all → copy" dance
admins were doing anyway. CSV export takes the same path (paste straight
into Sheets / Excel).

The modal keeps a "Download as file" button so admins who want the file
(sharing with colleagues, archiving, post-processing tooling) aren't
worse off than before — fully additive.

XMP (ZIP archive) and JSON exports keep their direct download path. A
textarea preview is the wrong UI for a binary archive, and JSON is
structured tool input where the file form is the natural mode.

Implementation:

- ExportPreviewModal — readonly textarea, copy + download buttons,
  monospace font for filename lists, click-to-select-all on the textarea
  for browsers that block clipboard writes (older Safari, hardened
  sandboxes — the catch falls through to a "select and copy manually"
  toast instead of silent failure).
- photosService.exportPhotosAsText — same backend endpoint as
  exportPhotos but resolves the blob.text() and returns
  { content, filename } instead of triggering a download. Preserves
  the existing exportPhotos for the XMP / JSON paths.
- PhotoExportMenu — PREVIEW_FORMATS = ['txt', 'csv']; non-preview
  formats keep the direct-download flow unchanged.
- EN + DE i18n entries.

No backend changes. No new endpoints. No breaking changes for callers
of photosService.exportPhotos.
2026-06-17 23:35:19 +02:00
Paul Nothaft 9d3daa1f93 Merge pull request #632 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.63.0-beta.0
2026-06-17 23:28:59 +02:00
github-actions[bot] d49a4d425d chore(beta): release 3.63.0-beta.0 2026-06-17 21:28:24 +00:00
Paul Nothaft 06b95a2ee7 Merge pull request #630 from the-luap/fix/lightroom-export-623
fix+feat: bundled bugfixes — Lightroom (#623), hero gap (#624), stale cache (#625), publish password (#627), low-memory OOM (#628), duplicate gallery (#626)
2026-06-17 23:27:55 +02:00
Paul Nothaft e985d25207 feat(events): duplicate-gallery action (#626)
Daniel asked for a way to re-use a good gallery configuration without
re-entering every setting. Two of his three suggested workflows are
covered by this PR; the third (per-event-type behaviour defaults) is
partially shipped already via event_types.theme_preset + theme_config
and is left as a follow-up if the duplicate workflow doesn't cover it.

Backend — POST /admin/events/:id/duplicate. Validates a new event_name
(required) + event_date (optional) + customer_name/email (optional);
copies branding (color_theme, css_template_id, header/hero/divider/anchor),
behaviour toggles (allow_downloads, watermark_*, allow_user_uploads,
require_password, etc.), photo_cap, welcome_message, default_photo_sort,
admin_email, and feedback settings + per-event photo categories. Mints a
fresh slug + share_token + random-placeholder password_hash (admin sets
the real one via the publish dialog shipped in #627). Recomputes
expires_at = new_event_date + (source.expires_at - source.event_date) so
the duplicate keeps the same active window; defaults to 30 days if either
source field was null. is_draft is always true.

Deliberately NOT carried over: photos, hero_photo_id, client_access
secrets, og_image_share opt-in, customer_phone, sent_at flags, archive
state, customer-account assignments.

Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog
pattern), wired into the Actions card on EventDetailsPage. Visible in
both draft and live mode since admins typically duplicate from a
published gallery. On success the page navigates to the new draft so the
admin can finish customising + publish.

I18n: EN + DE entries for the dialog + button label. Backend logs an
event_duplicated activity with the source event id/name so the trail is
auditable.

Frontend service: eventsService.duplicateEvent(eventId, data).
2026-06-17 23:16:12 +02:00
Paul Nothaft 714a9f6fb1 fix(upload): auto-throttle on low-memory hosts + correct documented RAM minimum (#628)
The README claimed 2GB RAM as the minimum, but two background-processor
worker loops × sharp.concurrency(2) means up to four libvips threads can
decode full-resolution images in parallel — peak RSS lands at 1.5GB+ on
a batch of 20MP+ photos. Add Postgres + Redis + Node baseline and one
heavy batch on a 2GB VPS OOM-kills the backend, surfacing as 503s on
thumbnails until restart:unless-stopped brings it back. Reported in #602,
filed as #628.

Three changes, smallest-surface-area each:

1. backgroundProcessor.js — on startup, when UPLOAD_PROCESSOR_CONCURRENCY
   is NOT set and os.totalmem() reports < 3GB, default to 1 instead of 2
   and log a one-shot warning naming the override env var. Explicit env-var
   setters keep their value. os.totalmem() reports container memory under
   cgroup v2 so this works in Docker / k8s as well as bare metal.

2. README.md — bumped the documented minimum from 2GB to 4GB, kept 2GB
   only as a "Low-memory hosts" recipe pointing at UPLOAD_PROCESSOR_CONCURRENCY=1
   with the throughput trade-off spelled out. Added the 503-on-OOM symptom
   so the next reporter finds it via search.

3. docker-compose.production.yml — commented mem_limit / memswap_limit
   example on the backend service. Off by default (don't surprise existing
   deployments) but visible to operators thinking about shared/multi-tenant
   hosts. restart:unless-stopped already on every service.

No code path for memory-aware runtime throttling (Luca's option 4) — out
of scope for a bug fix; tracked separately if #1-#3 don't close the case.
2026-06-17 23:04:30 +02:00
Paul Nothaft 83b568ee2d fix(events): publish-from-draft email carries the real password (#627)
Previously, publishing a password-protected DRAFT gallery sent the
gallery_created email with the literal sentinel "(set at creation)",
which the email processor localised to "The password you set when
creating the gallery" / "Das bei der Erstellung der Galerie gesetzte
Passwort". Root cause: at draft creation only the bcrypt hash is stored
(no plaintext column, by design); the publish endpoint had nowhere to
pull the actual password from. Create-and-publish-in-one-step worked
because the plaintext is still in memory at email-queue time.

Fix: the Publish action now opens a small PublishGalleryDialog that
prompts the admin to (re-)type the gallery password. The publish
endpoint accepts an optional `password` body, re-hashes + writes
`password_hash` so the stored hash matches what was just emailed (admins
who mistype at creation get a self-healing publish flow), and puts the
plaintext into the gallery_password email field. When the publish call
is made without a password (API-only consumers), behaviour falls back
to the legacy sentinel — no breaking change.

The window.confirm() publish flow is gone; the dialog handles the no-
password case too (plain confirm + Publish button).

I18n: EN + DE entries for the dialog. Other locales fall through to
the EN defaults via the t() default-value pattern.

No schema changes. No plaintext at rest.
2026-06-17 22:58:17 +02:00
Paul Nothaft ea6245cfde fix(gallery): admin edits to welcome_message land for returning guests (#625)
GalleryAuthContext cached the event in sessionStorage on first visit and
then SKIPPED the server fetch on returning visits (`if (!storedEvent)`),
so a guest who'd already opened the gallery would never see admin edits
to welcome_message / event_name / hero_logo / colour theme — sessionStorage
survives Cmd+Shift+R, so the only escape was closing the tab or wiping
site data manually.

The cached event is still shown above as an instant placeholder for
perceived perf, but the server fetch is no longer gated: on every mount
the fresh row overwrites both React state and the sessionStorage entry.
Cost is one extra /gallery/:slug/photos request per gallery navigation
when the session is already authenticated; benefit is admin edits
propagating on next page load for everyone.
2026-06-17 22:47:48 +02:00
Paul Nothaft 6d6718abe2 Merge pull request #629 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.62.0-beta.0
2026-06-17 22:46:46 +02:00
Paul Nothaft 178d6dafb1 fix(gallery): leave a visible gap between filter bar and hero header (#624)
When a gallery uses the 'hero' header_style AND the admin enables the
filter bar (search + sort), the search/sort row glued itself to the top
of the hero image. Root cause: HeroHeader carries a decorative `-mt-6`
on its outer div (so it can bleed flush against the page header when
nothing else is above), and that exactly cancelled the wrapper's `mt-6`
between PhotoFilterBar and PhotoGridWithLayouts.

Fix: when the filter bar is shown above a hero header, the grid wrapper
uses `mt-12` instead of `mt-6` so the hero's bleed leaves a 24px net gap
rather than zero. The no-filter-bar case keeps the original flush bleed.

Also tidied up: extract the filter-bar-shown predicate to a named const
so the two reads (conditional render + wrapper class) can't drift apart.
2026-06-17 22:31:04 +02:00
Paul Nothaft a239fec9d7 fix(admin/exports): Lightroom TXT export joins with comma + drops extension (#623)
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom
search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's
filename search wants a comma-separated one-liner, and the gallery JPEGs may
correspond to RAW files in the catalog — so the search has to match on the
stem only.

The frontend now passes `separator: 'comma'` + `include_extension: false` for
the TXT format specifically. The backend gains an `include_extension` option
(defaulting to true so direct API consumers don't break), and the comma case
joins without a trailing space (the form Lightroom expects). Unit test pins
the Lightroom-mode output AND the backward-compatible default for any direct
API caller.

CSV / XMP / JSON exports are unchanged.
2026-06-17 22:25:12 +02:00
github-actions[bot] ccdb5b9823 chore(beta): release 3.62.0-beta.0 2026-06-17 20:18:03 +00:00
Paul Nothaft f279771ee8 Merge pull request #622 from Luca-Timo/feat/accounting-inbound-invoices
Accounting suite + CRM hardening
2026-06-17 22:17:22 +02:00
Luca 8deb7e0741 fix(accounting): tidy the tax-export scope selector styling
The scope <select> inherited `w-full` from the shared selectClassName, so it
stretched the whole row on its own line (the ledger-format select overrides it
with w-auto; this one didn't). Give it `w-auto min-w-[140px]` and wrap it in an
inline "Scope" label so the Report row reads compactly as
"Scope [Complete ▾] [Export CSV] [Export PDF]", consistent with the journal row.
2026-06-16 19:13:25 +02:00
Luca 116743ba43 docs(readme): add CRM + accounting to features, tax disclaimer, update contributor
- New "For Studios — CRM & Accounting (Beta)" subsection under Key Features
  (quotes→contracts→invoices+Storno, hours/calendar, inbound supplier invoices +
  expenses, tax report + Treuhänder/Banana export, VAT) and updated the Roadmap
  beta-table row to "CRM & Accounting Module".
- Broadened the disclaimers section to CRM & Accounting and added a Tax/VAT
  bullet: figures are guidance only + jurisdiction-specific (e.g. the LI 20%
  Gewinnungskosten flat rate), and every operator must verify their own tax/VAT
  regulations with their accountant/Treuhänder/tax authority before relying on
  any figure or export.
- Updated the @Luca-Timo contributor entry with a concise CRM + accounting credit.
2026-06-16 19:05:38 +02:00
Luca 86dff75898 test(accounting): cover export scope, unique-violation detector, PDF page cap
Closes the test gaps from the PR #622 work + the export-scope feature:
- export scope: scopeLedger/normalizeScope (exported via _internal) unit tests +
  renderTaxReportCsv income/cost/all output assertions (income drops supplier
  rows, cost drops invoice rows, filename gets the scope tag).
- isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint
  failed" message, false for FK + nullish (the IMAP claim-first race detector).
- getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before
  touching pdftoppm/disk (the per-file resource bound).
2026-06-16 18:54:55 +02:00
Luca 9f3b28684f feat(accounting): scope the tax-report export to income-only or cost-only
Adds a Complete / Income only / Cost only selector to the readable PDF + CSV
export (the on-screen report stays complete). Income-only emits just the
outgoing rows + the income summary line (+ the per-rate breakdown in the PDF);
cost-only emits the incoming-invoice + expense rows + the cost line and drops
the income-by-rate breakdown. Useful in Liechtenstein where, under the income
threshold, a flat 20% Gewinnungskosten deduction is sometimes better than actual
costs — handing the Treuhänder just the income (or just the cost) basis is
cleaner.

Backend: renderTaxReportPdf/Csv take a `scope` param (all|income|cost) that
filters report.ledger by row.type + the summary lines; the /pdf + /csv routes
accept & validate `?scope=`; filenames get an income_/cost_ tag. Frontend:
scope <select> beside the export buttons, threaded through buildQueryString.
i18n en/de. The 20% calculation itself is intentionally NOT in-app (applied by
the Treuhänder) per the scoping decision.
2026-06-16 18:51:11 +02:00
Luca d6da89f48a chore(accounting): PR #622 nits — stray artifact, dedupe requireFlag, IMAP poll backoff
1. Remove the committed test artifact backend/storage/business-docs/quote/2026/
   Q-2026-0001.pdf and gitignore backend/storage/business-docs/ so generated CRM
   docs can't be committed again.
2. adminLedger + adminExpenses dropped their local requireFlag copies and now
   import the shared (now cached) requireFeatureFlag middleware.
4. roundTripTest polls IMAP with ×1.5 backoff (cap 8s) instead of a flat 3s, so a
   30s test takes ~5 SELECT/SEARCH locks not ~10 (some servers throttle).

Nit 3 (dashboard + events pages still on the gallery-theme vars, not dark-mode-
swapped) is left as a documented follow-up per the review.
2026-06-16 18:36:35 +02:00
Luca a93b6dc232 fix(accounting): PR #622 concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
   gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
   still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
   getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
   override — instead of the per-customer column alone, via a shared
   customerFeatureAllowed() helper. Admin disabling a feature globally is now
   honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
   from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
   registered" and hid the reclaim). Treat null as "not configured":
   vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
   a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
   loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
   MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
   hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
   dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.

Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
2026-06-16 18:33:47 +02:00
Luca cd6d57839b fix(accounting): PR #622 blockers — CSV formula injection + IMAP double-ingest race
Blocker 1 — CSV/Banana formula injection. Neither csvEscape (ledgerService) nor
the tax-report CSV escape nor the unquoted tab-separated Banana cell formatter
prefixed risky leading chars, so an admin-/sender-controlled cell beginning with
= + - @ TAB CR executes as a formula when the Treuhänder opens the export. New
shared util neutralizeSpreadsheetFormula() prepends a single quote; wired into
all three sinks (quoted CSV + unquoted Banana). Unit test pins one of each char.

Blocker 2 — IMAP intake double-ingest race. received_emails.message_id was
INDEX, not UNIQUE, and the poller ingested attachments BEFORE writing the audit
row, so a second replica / rolling-deploy overlap double-ingested the same mail.
Migration 128 makes message_id UNIQUE (nulls stay distinct); the intake now
CLAIMS the message row (status='processing') BEFORE ingesting — a concurrent
claim hits the unique constraint and skips cleanly (shared isUniqueViolation
helper). Stale 'processing' rows (worker crashed mid-ingest) are reclaimed after
10 min so no attachment is orphaned.

NOT done (deliberate): the suggested UNIQUE on inbound_documents.file_sha256 —
that column is a SOFT dedup key by design (manual re-uploads are kept as flagged
'duplicate' rows + duplicate_of_id for the Duplikat disposition); a unique index
would break that feature. The file race only yields an extra 'unsorted' row (a
data-quality nit, caught by the existing manual Duplikat backstop), not a
double-count. Rationale to be added to the PR reply.
2026-06-16 18:21:02 +02:00
Luca a7c19135bb fix(branding): force lock = light/dark only; Branding stays the full preset, galleries hide color+mode
Reworks the previous force-mode UX per the intended model:
- Branding page IS the global preset — keep presets, colors, fonts and style
  fully visible. The only change under a force lock: hide the redundant
  per-theme Color Mode picker (light/dark is the Force control), with a hint.
- Force only locks light/dark again: reverted applyForceColorMode to swap the
  surface palette only — it no longer resets typography/style, so the branding
  fonts/style always apply.
- Per-event GALLERY theme editors now receive the global force value and, when
  a lock is active, hide the colour pickers AND the light/dark picker (a gallery
  can't override the site-wide lock). Presets/fonts/layout stay. Force off →
  everything returns.

Wiring: CreateEventPage + EventDetailsPage pass
forceColorMode={publicSettings?.branding_force_color_mode} (value only, no Force
control). Branding keeps both the value and the onForceColorModeChange handler,
which is how the component tells the two contexts apart. i18n en/de.
2026-06-16 15:54:59 +02:00
Luca 1ac653ad1b fix(branding): when a force lock is active, collapse the theme customizer to just the Force control
Follow-up to the force-mode change: hide ALL gallery theme customization while
a force light/dark lock is on, not only colors + typography. Theme presets,
gallery layout, header style, controls style, the colour pickers (incl. accent),
Typography & Style, CSS templates, the PDF-typography slot and event custom CSS
are all hidden — only the Force color mode control (with an explanatory note)
and the Reset/Apply actions remain. Accent brand colours still apply to the
gallery; their picker is just hidden while the lock is on. Turning the lock off
restores the full customizer. Note text + i18n (en/de) updated.
2026-06-16 15:42:10 +02:00
Luca 4749e222dc feat(branding): force color mode = standard look; hide overridden theme controls
When a force light/dark lock is active it now means "use the clean standard
look": applyForceColorMode also resets typography & style (fonts, size, corner
radius, shadow, background pattern) to defaults — on top of the surface/text
palette it already swapped — so those settings genuinely don't apply while the
lock is on. Accent brand colours and the structural cards (header/controls/
gallery layout/hero divider) are preserved. Override-only: the saved theme keeps
the admin's custom values, so turning the lock off restores them.

In the theme customizer, when a force mode is active, hide the now-dead controls
to avoid confusion — the per-theme Color Mode picker, the Surfaces + Text colour
pickers, and the whole Typography & Style card — and show an explanatory note.
The Force picker itself and the Accent pickers stay visible. i18n en/de.

This pairs with the admin dark-mode fix: admin surfaces follow the `.dark` class
which AdminDarkModeContext drives from the force lock, so force is respected
end-to-end.
2026-06-16 15:08:14 +02:00
Luca d3266a0d1c fix(crm): admin surfaces follow the admin light/dark toggle, not the gallery theme (#620)
CRM + accounting admin surfaces read gallery-theme tokens — the
`text-theme`/`text-muted-theme` utility classes and raw `var(--color-surface|
text|surface-border|elevated)` inline styles — which `ThemeContext` writes as
inline `--color-*` on <html> for every route. Those inline vars beat the admin
`.dark` toggle, so e.g. the "Create passive customer" modal (#620) renders dark
while the admin is in light mode (and the reverse).

Repoint every CRM/accounting admin surface to Tailwind `dark:` classes so it
tracks the admin toggle deterministically:
- text-theme → text-neutral-900 dark:text-neutral-100; text-muted-theme →
  text-neutral-500 dark:text-neutral-400 (across the CRM list/detail/editor
  pages, hours, calendar, lineage, installments, CRM settings).
- modal/dropdown/chip/divider inline `var(--color-surface*)` → bg-white
  dark:bg-neutral-900 / border-neutral-200 dark:border-neutral-700 etc.
  (CustomerManagement + CustomerDetail modals = the #620 fix).
- toggle off-track surface-border → bg-neutral-300 dark:bg-neutral-600; brand
  accent ON-state kept (var(--color-accent)).
- CalendarPage FullCalendar chrome: scope local --cal-* vars under .fc / .dark
  .fc so the calendar follows the admin toggle (was reading gallery vars).
- PasswordResetModal bare neutrals + TaxReport Storno/Reissue badges gain dark
  pairings.

Brand accent/primary tokens and the branded admin login are intentionally left
on the gallery theme. The same leak exists in non-CRM admin areas (dashboard,
events) — out of scope here.
2026-06-16 14:54:25 +02:00
Luca 03fa3d8296 fix(flags): close CRM/accounting feature-gating gaps from the audit
A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:

- Hours logging: only createEntry checked the flag — edit/delete/bill and
  the list/summary routes were permission-only. Gate all six
  /hour-entries routes on the hoursLogging master so a disabled feature
  can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
  but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
  the list + quotes routes have. Also fixes the quotes-PDF gate, which
  read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
  neither the master nor a per-customer column.

Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
  existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
  detail page gains the toggle; service/validator/serializer wired.

Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
  to Accounting); add the missing `projects` so it mirrors the context
  derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
2026-06-16 13:12:26 +02:00
Luca 873be910a5 feat(accounting): data-driven revenue-rate VAT map (multi-country)
The "VAT code by revenue rate" rows were hardcoded to the Swiss/LI rates
(8.1/2.6/3.8/0), so a code at any other rate (e.g. DE 19%/7%) had no row
to map. Derive the rows from the distinct rates of the OUTPUT VAT codes
instead — retype a code to a local rate and its row appears automatically;
remove the last code at a rate and the row drops. The CH/LI seeds are
unchanged and still produce the same four rows.

Frontend rateKey() mirrors backend ledgerService.rateKey so the saved map
keys keep matching the export-time lookup. Each rate's dropdown is scoped
to output codes at that rate. Empty state when no output codes exist.
2026-06-16 12:43:36 +02:00
Luca 8621338c48 fix(settings): don't insert non-existent created_at into app_settings
app_settings has no created_at column (src/database/db.js defines only
setting_key/value/type + updated_at), so inserting one threw — which
broke saving any FIRST-TIME setting key. Existing keys took the UPDATE
path and worked, hiding the bug; it surfaced on the new VAT-registration
toggle + reclaim-countries keys ("Failed to save accounting settings").
Also fixes the same latent failure on the customer-surface settings route.
2026-06-16 01:23:54 +02:00
Luca 97795f6d1e feat(accounting): move Chart of accounts into Settings → Accounting
Consolidate all accounting configuration in one place. The Chart of
accounts (accounts table + category/default-account mappings) becomes a
self-contained ChartOfAccountsManager rendered in Settings → Accounting,
next to the VAT codes that already moved there. The /admin/accounting
section is now purely operational (Incoming invoices · Expenses · Tax).

The old /admin/accounting/ledger route redirects to the settings tab so
bookmarks keep working; the Tax page "Configure" link points there too.
ChartOfAccountsManager saves only the account keys (partial-merge safe,
same as VatCodesManager), so the two never revert each other's edits.
2026-06-16 01:11:12 +02:00
Luca 4ff5b84cb6 feat(accounting): relocate VAT codes + rate maps into Settings → Accounting
Move VAT-code CRUD and the rate→code / treatment→code maps off the
Chart-of-accounts page into a self-contained VatCodesManager rendered in
Settings → Accounting, so all VAT config lives in one place. CoA keeps
the accounts table, default/system accounts, and expense-category maps.

Both pages save disjoint key sets through the partial-merge updateSettings
(CoA → account keys only; VatCodesManager → ledger_vat_map +
ledger_output_vat_map only), so neither reverts the other's edits.
2026-06-16 00:45:50 +02:00
Luca d7107aaf0a feat(accounting): tax report VAT-payable honours registration + reclaim
The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT
minus the RECLAIMABLE input VAT only (costs with tax_treatment
foreign_vat_non_reclaimable are excluded from the deduction). Registration reads
accounting_vat_registered; when unset it falls back to a behaviour-preserving
heuristic (charged output VAT this period ⇒ registered), so existing reports are
unchanged and non-VAT installs correctly show 0. loadCosts now tracks
reclaimableVat. Tests updated; 32 pass.
2026-06-16 00:33:10 +02:00
Luca 4d87684882 feat(accounting): VAT registration + reclaim-country settings in the Accounting tab
Adds the 'VAT registration & reclaim' section to Settings → Accounting: a
'VAT-registered' toggle (charge output + reclaim input VAT) and a multi-select
of countries whose input VAT is reclaimable (default domestic CH/LI). Wires
accounting.service + the backend keys added earlier (accounting_vat_registered,
accounting_vat_reclaim_countries). i18n en/de. The report VAT-payable math that
consumes these is the next slice.
2026-06-16 00:27:17 +02:00
Luca 2479d87afc feat(accounting): bill editor VAT dropdown + GET returns vat_code snapshot
Slice 2 + 1b:
- Bill editor: VAT-rate field → VatRateSelect dropdown (mirrors the quote
  editor); snapshots vatCode on create + carries it from a source quote.
- getQuoteById + the invoice serializer now return vat_code, so re-editing a
  saved document preserves the snapshot instead of falling back to the
  rate→code map. Payload types (quotes + bills) carry vatCode.

72 tests pass; build green.
2026-06-16 00:20:59 +02:00
Luca 6e1924bae8 feat(accounting): VAT-code dropdown in the quote editor (+ reusable VatRateSelect)
Slice 3a — replaces the free-typed VAT rate in the quote editor with a dropdown
of configured output VAT codes (+ 'Other (custom rate)'), reading the un-gated
/admin/vat-codes endpoint. Selecting a code sends vatCode → the backend snapshots
it (migration 130) and the export emits it. New VatRateSelect component + a
read-only vatCodes.service. Create flow snapshots correctly; loading a saved code
into the editor (serialization return) + the bill editor are the next slices.
Build green.
2026-06-16 00:08:52 +02:00
Luca fbbbb8ab73 feat(accounting): VAT registration/reclaim settings + un-gated VAT-codes read
Slice 1 of the VAT consolidation backend:
- PUT /admin/settings/accounting accepts accounting_vat_registered (bool) +
  accounting_vat_reclaim_countries (ISO-2 list); GET /:type already returns
  them parsed, so no GET change needed.
- New read-only GET /api/admin/vat-codes (adminAuth, NOT accounting-gated) so
  the invoice/quote editors can populate their VAT dropdown even when the
  accounting layer is off. Management CRUD stays under /admin/ledger.
2026-06-16 00:02:59 +02:00
Luca 5b52969e36 feat(accounting): snapshot the chosen VAT code on quote/invoice create + storno
Wires the vat_code snapshot (migration 130) through the write paths: quote
create/update, the main invoice create, and the Storno carry-over (so a
cancellation exports the same code as the invoice it reverses). Guarded with
hasColumnCached; reads payload.vatCode (sent by the editor dropdown, coming in a
later slice — inert until then, falls back to the rate→code map). 72 tests pass.
2026-06-15 23:57:23 +02:00
Luca 0a7dc1cf5d feat(accounting): snapshot vat_code on quotes/invoices + export prefers it (foundation)
First slice of the VAT-consolidation: migration 130 adds a nullable vat_code
snapshot column to quotes + invoices, and the Treuhänder export now prefers the
invoice's snapshotted code over the (mutable) rate→code map, so a historical
invoice's VatCode never changes when codes are re-mapped. Schema-drift guarded;
behaviour-neutral until the editors start writing the snapshot (next slices).

Part of: VAT registry → Settings→Accounting, invoice VAT dropdown, registration/
reclaim toggle.
2026-06-15 23:53:32 +02:00
Luca 53a16f9f6f fix(accounting): Banana I&E export uses the 'Category' column (not 'ContraAccount')
Real Banana Income & Expense files name the category column 'Category', not
'ContraAccount' (which the doc listed but is a double-entry concept) — so the
income/expense account never landed and Banana warned 'ContraAccount column not
found'. Use 'Category'. VatCode stays (it only warns on a non-VAT-enabled file;
amounts are gross). Test updated.
2026-06-15 23:17:57 +02:00
Luca 0c0fb29770 fix(accounting): emit ISO dates in exports (Postgres returns Date objects)
The Date column imported empty into Banana because dateOnly() did
String(d).slice(0,10) — on Postgres the date columns come back as JS Date
objects, so that yields "Thu Jan 15" instead of "2026-01-15", which Banana
rejects. (SQLite returns strings, so the tests never caught it — the
pg-date-serialisation trap.)

- ledgerService.dateOnly + taxReportService CSV now format Date objects to
  yyyy-mm-dd via local calendar parts (DATE columns are local-midnight).
- Regression test added with a real Date object (the existing tests all used
  string dates).
2026-06-15 23:01:20 +02:00
Luca 445d6d7b6d feat(accounting): add a Banana "Income & Expense" (cash-book) export format
The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.

Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
  Description, Income, Expenses, ContraAccount (the income/expense account),
  VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
  cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
  both Banana variants. Labels relabelled: "Banana — double-entry" and
  "Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.

Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
2026-06-15 22:48:06 +02:00
Luca 74144da45f fix(accounting): UTF-8 BOM on the ledger export so Banana reads it correctly
The /ledger/export route sent the file without a BOM, so Banana (and Excel)
decoded it as the local charset — the '·' description separator and any umlauts
imported as mojibake ('·'). Prepend the EF BB BF BOM like the tax-report CSV
route already does.
2026-06-15 22:39:32 +02:00
Luca a19506749a fix(accounting): Banana export is now a tab-separated .txt (actually importable)
Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.

- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
  with no quoting, .txt extension, text/plain content-type. generic + bexio stay
  comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).

The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
2026-06-15 22:28:10 +02:00
Luca b584aaf7a6 test(accounting): update tax-report CSV tests for the unified ledger format
The CSV rework (unified, typed ledger) replaced the 'Rechnung' column with
'Referenz' (+ a 'Typ' column) and dropped the separate cancelled 0/1 column in
favour of a localised '(Cancelled)' suffix on the Reference cell. Update the
two assertions in taxReportPdf.test.js accordingly. All 11 cases pass.
2026-06-15 19:54:18 +02:00
Luca 7e586cd3a1 style(accounting): align tax-export buttons + solid divider between groups
- Give all four export controls (CSV / PDF / format select / Accountant export)
  a matching min-width so the two rows form a tidy right-aligned button grid
  (CSV over format select, Export PDF over Accountant export).
- Replace the dashed sub-divider between the Report and Accounting journal
  groups with a solid line so the separation reads clearly.
2026-06-15 19:30:22 +02:00
Luca 3edd832103 feat(accounting): clearer tax-export window + gate journal export on accounting flag
- Restructure the export area into two labelled groups: 'Report' (PDF/CSV,
  for you) and 'Accounting journal' (for your accountant), each with a
  one-line caption — instead of two unlabelled button rows.
- i18n: the English label was the German 'Treuhänder export' → now 'Accountant
  export' (de stays 'Treuhänder-Export'); hint reworded.
- Feature flags: the journal export is an accounting-layer feature (needs the
  Chart-of-accounts mapping), so gate it on the 'accounting' master — the
  group only renders when accounting is on, and the backend /export route no
  longer requires the 'taxReport' sub-flag (the router already requires accounting).

Build + node --check + JSON parse green.
2026-06-15 19:18:36 +02:00
Luca b1f73c1df9 feat(accounting): move Treuhänder export onto the Tax page
The standalone 'Treuhänder export' tab duplicated the Tax page's period/
currency filters over the same data. Fold the collective-journal export into
the Tax page as a third export action (target-tool format picker: generic /
Banana / bexio), beside Export CSV/PDF, with a link to its Chart-of-accounts
config. Removes the Accounting sub-nav 'export' tab (old /export route now
redirects to the Tax page); keeps Chart of accounts as its own setup tab.
Deletes the now-orphaned LedgerExportPage.

Build + JSON parse green.
2026-06-15 18:57:43 +02:00
Luca f3e77e7807 fix(accounting): label the outgoing-invoice totals block in the tax summary
The summary card's top block (Total net/VAT/gross) is the outgoing-invoice
totals but had no section header, unlike the 'Income / costs' block below.
Add an 'Outgoing invoices' (de: 'Ausgangsrechnungen') header to match.
2026-06-15 17:48:22 +02:00
Luca fd1dd81e8d feat(accounting): unify tax report into one signed, typed, sortable ledger
Replaces the separate revenue + costs tables with a single ledger across the
screen, CSV and PDF. Every row is typed (outgoing invoice / incoming invoice /
expense) and signed — outgoing positive, incoming + expenses negative — so
sorting by value runs income → costs and the column nets toward the Result.

- getTaxReport now returns a `ledger` array (signed, typed, date-sorted);
  legacy rows/costs/summary kept for back-compat.
- Frontend: one sortable table (click Type/Date/Party/Net/VAT/Gross), coloured
  type badges, cancelled rows greyed with lineage badges; Income/Costs/Result
  summary box unchanged.
- CSV + PDF reworked to the same unified, signed layout; PDF totals show
  Income / Costs (negative) / Result.
- i18n: en/de (frontend) + pdf-i18n (en/de real; fr/nl/pt/ru English-fallback,
  flagged for native review).

Build + node --check + JSON parse green.
2026-06-15 17:30:55 +02:00
Luca ab65a470a0 fix(accounting): tax report cost side queried a non-existent column
The tax-report cost query selected inbound_documents.description, but that
column only exists on the 'expenses' table — inbound_documents has none. On
Postgres this threw 'column inbound_documents.description does not exist',
so the whole cost side failed with 'Costs could not be loaded'.

Use inbound_documents.invoice_number (an existing column, same descriptor
ledgerService surfaces) as the cost-row label instead. Expense rows still
use their real expenses.description column.
2026-06-15 16:58:44 +02:00
Luca 402dbde0a1 Merge origin/beta into feat/accounting-inbound-invoices
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
2026-06-15 16:37:23 +02:00
Paul Nothaft 539f93551a Merge pull request #618 from Luca-Timo/fix/maintenance-locks-out-admin-login
Enabling maintenance mode locks every admin out of the panel
2026-06-14 00:06:06 +02:00
Paul Nothaft b757235b1a Merge pull request #619 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.61.0-beta.0
2026-06-14 00:05:54 +02:00
github-actions[bot] bd07fc474d chore(beta): release 3.61.0-beta.0 2026-06-13 21:47:18 +00:00
Paul Nothaft 58f93ae713 Merge pull request #616 from Luca-Timo/feat/crm-improvements
feat(projects): Project Overview cockpit — link (multiple) quotes/contracts/hours into projects
2026-06-13 23:46:51 +02:00
Luca fdde4696e7 fix(maintenance): never block /admin/* with the maintenance screen
The previous wrapper gated /admin/* on a /auth/session check and only showed
the panel when an admin session was detected. Two failures:

1. The session check effect depended on `isAdminRoute` (a boolean), so the
   client-side login → dashboard navigation (both /admin/*) never re-ran it.
   hasAdminSession stayed stale-false from the logged-out /admin/login render,
   so a freshly logged-in admin landed on the maintenance screen anyway.
2. It also hid /admin/login itself (the catch-22).

Fix: the maintenance screen only blocks customer/gallery/public routes —
/admin/* is never blocked. The admin auth layer already handles access
(AdminLayout redirects a logged-out admin to /admin/login), so no session
probe is needed here. Removes the fragile /auth/session dependency entirely.

Backend skipPaths (/api/auth/admin/login + /api/auth/session) stays: login and
AdminAuthContext's token validation must still work during maintenance.
2026-06-13 14:18:02 +02:00
Luca 249313072b fix(maintenance): enabling maintenance mode no longer locks admins out
Turning on maintenance mode locked out every admin — including ones already
logged in — with no way back in from the browser. Two causes:

1. Backend (middleware/maintenance.js): the skipPaths allow-list pointed at
   /api/admin/login and /api/admin/auth/login, but the real admin auth routes
   live under /api/auth (POST /api/auth/admin/login, GET /api/auth/session).
   So during maintenance both the login POST and the session check 503'd. The
   503 on /auth/session made the frontend read every admin as logged-out, and
   also tripped the axios interceptor that force-enables maintenance globally.
   Fixed the allow-list to the actual endpoints.

2. Frontend (MaintenanceWrapper.tsx): the maintenance screen rendered over
   every /admin/* route unless an admin session already existed — covering the
   /admin/login page itself. A logged-out admin could never reach the form to
   get a session (catch-22). /admin/login is now always allowed through.

With both: a logged-in admin keeps working (session check passes), and a
logged-out admin can reach /admin/login and sign back in, all while
maintenance mode correctly blocks customers.
2026-06-13 13:57:21 +02:00
Luca f74d8d4e8c fix(projects): "one customer matches" rule for deal-lineage attach
Single-customer projects, but content is addable whenever ONE of its customers
is the project's customer (not only when the first lineage customer equals it):

- linkDealToProject: collect ALL customers across the deal's quote/contract/
  invoice lineage and reject only when none is the project's customer. Mirrors
  the events path, where a multi-customer event already attaches if any of its
  customers matches. Adoption onto an empty project unchanged.
- assignDocument: a document carries one customer, so equality stays correct;
  message aligned with the lineage check.
2026-06-13 13:12:58 +02:00
Luca 4b1e85c855 fix(projects): enforce single-customer projects (guard event attach + re-label)
A project must stay tied to one customer. The quote/contract/hours attach
paths already rejected a foreign customer (equality on project.customer_account_id);
the two remaining holes are closed here:

- assignEvent: an event may only join a project that shares its customer. The
  event's customer(s) come from event_customer_assignments; a customer-assigned
  project rejects an event for a different customer (PROJECT_CUSTOMER_MISMATCH),
  and an empty project ADOPTS a single-customer event's customer. This is why
  a foreign-customer event could previously be attached.

- updateProject: re-labelling a project to a customer that conflicts with the
  events/quotes/contracts it already holds is rejected (clearing to null is
  still allowed), so the customer can't be swapped out from under existing
  content.

Frontend: the cockpit attach-event action surfaces the translated mismatch
message; projects.error.customerMismatch reworded to read for both documents
and events (de + en).
2026-06-13 13:09:55 +02:00
Luca 9d13880f2b fix(projects): address review — cross-customer guards + email/queue hardening
Resolves the two blockers and the actionable concerns/nits from review.

Blockers (cross-customer leak):
- linkDealToProject: collect the deal's customer + events BEFORE any write,
  then reject a cross-customer link with PROJECT_CUSTOMER_MISMATCH (422) before
  re-pointing events/quotes/contracts or adopting a customer. The editors set
  project_id via quoteService/contractService → linkDealToProject (not
  assignDocument), so the guard lives at that chokepoint. Null-project adoption
  ("first deal wins") preserved as intended.
- assignDocument: boundary guard mirroring customerHoursService, defense-in-depth
  ahead of the cascade.
- Frontend: translated PROJECT_CUSTOMER_MISMATCH (projects.error.customerMismatch,
  de+en) wired into HoursSection + quote/contract editor onError (concern 5).

Concerns:
- 1: processEmailQueue gains an onlyId option; cockpit "send now" scopes the
  flush to the single row so it can't force-retry other dead-lettered emails.
- 2: resendEmail re-stringifies email_data when PG returns a parsed object,
  matching the canonical enqueue — no jsonb double-encode.
- 3: cockpit email feed scoped to the project's own document numbers (event_id
  for gallery mails; email_data doc-number match for CRM mails) instead of the
  recipient string — a shared inbox no longer leaks another customer's mail.
- 4: migration 117 backfill wrapped in a transaction (adds atomicity on SQLite,
  where the runner does not wrap; PG already wraps the whole migration).
- 6: resend/cancel/retry/sendNow now logActivity uniformly (project_email_*),
  adminId threaded from the route.
- 8: validator optional({ values: 'null' }) → optional({ nullable: true }).
- 9: pre-121 list valuation falls back to customer-scoped quotes so the list
  isn't all-zero during the upgrade window.

Nits:
- milestone selection uses Array.at(-1); removed redundant in-loop require in
  emailProcessor; clarifying comments for the list/detail perms split and the
  count-vs-value (0 vs em-dash) convention.
2026-06-13 11:57:32 +02:00
Luca 3b70a09773 fix(accounting): 'Save & mark paid' actually pays; incoming invoices appear in tax/export
#1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize +
   markInboundPaid with the entered reference) instead of opening the pay dialog
   and leaving it unpaid. Removed the PayModal chain.
#2 Cost side missed captured incoming invoices: the query required
   currency='CHF', but email/upload invoices often have a null currency →
   silently excluded. Now include null-currency rows (treated as the report
   currency). Also replaced COALESCE(invoice_date, created_at) with a split
   date filter (invoice_date BETWEEN, else created_at range) to avoid the
   mixed date/timestamp comparison risk on Postgres. Same fix in the ledger
   export (buildPostings).
en/de: categorizedPaidToast.
2026-06-12 18:20:55 +02:00
Luca 663daf50ff fix(accounting): always show Income/Costs/Result summary on tax page (even with zero costs)
The Einnahmen-Ausgaben summary only rendered when costs existed, so a period
with no incoming invoices/expenses looked revenue-only. Now it shows whenever
the cost side loaded successfully (costs default to 0 → Result = Income), so
the income/result is always visible. Still hidden when the cost side errored
(the amber banner covers that case).
2026-06-12 17:57:54 +02:00
Luca bbceac6cb0 ci: make GHA cache export non-fatal (ignore-error=true)
The frontend/backend image builds + pushes succeed, then the final
'exporting to GitHub Actions Cache' step intermittently fails with
'error writing layer blob: not_found' (a known flaky type=gha cache backend
issue), failing the whole job. Add ignore-error=true to every cache-to so a
cache-write hiccup can't break an otherwise-successful, already-pushed build.
2026-06-12 17:56:06 +02:00
Luca 9f8511114a fix(accounting): tax report degrades gracefully if cost side fails (+ surface the error)
The cost side is supplementary — it must never 500 the core revenue report.
getTaxReport now wraps loadCosts in try/catch: on failure it returns empty
costs + a costsError string and logs the real error. The tax page shows the
revenue report plus a non-fatal amber banner with the cost-side error message,
so the actual cause is visible in the UI instead of an opaque 500.
2026-06-12 17:43:42 +02:00
Luca ea8f6bc88a fix(accounting): tax report 500 on Postgres — drop SQL date() from cost queries
The #4 cost side used 'date(COALESCE(invoice_date, created_at)) BETWEEN ...'
and 'date(created_at) BETWEEN ...'. The mocked unit tests never execute the
SQL, so the Postgres failure (date()/COALESCE(date,timestamp)) slipped through
and surfaced as a 500 on the live tax report. Replaced with plain range
comparisons (col >= from AND col <= '<to> 23:59:59.999') — valid on both PG and
SQLite, inclusive of the whole end day. Same fix applied to ledgerService
buildPostings (the Treuhänder export would have 500'd identically).
2026-06-12 16:57:34 +02:00
Luca 9514f5cb8e fix(accounting): distinguish Categorized (purple) from Paid (green)
Both badges were green; recolor the 'categorized' status to purple so the
status (categorized) and payment state (paid) read distinctly.
2026-06-12 16:41:59 +02:00
Luca 5fcb96c723 fix(accounting): PDF pager always shown, click categorized→pay, drop duplicate Paid chip
#1 DocumentPreview renders the page pager for every PDF (disabled at the ends),
   not only multi-page ones — so the control is visible on single-page invoices.
#2 Clicking a categorized (unpaid) invoice opens the Mark-paid dialog; new →
   categorize, paid/declined/duplicate → view.
#3 A paid row no longer shows two 'Paid' chips — the front badge is the status,
   and the right action becomes a quiet 'Mark unpaid' (revert).
2026-06-12 16:41:06 +02:00
Luca 72b784c9d7 feat(accounting): incoming-invoice triage refinements (paid badge, click-to-categorize, reference, categorize+pay)
#1 Row status reads 'Paid' (green) once supplierPaid — no longer the stale
   'categorized' badge.
#2 Clicking a new (unsorted) invoice opens the Categorize modal; sorted ones
   still open the read-only view.
#3 Triage gains a Payment reference field (persisted via updateInbound →
   payment_reference).
#5 Triage has two actions: 'Save' (categorize only) and 'Save & mark paid'
   (categorize, then chain into the mark-paid dialog with the reference
   prefilled).
#4 (mark-paid PDF nav) was already present via DocumentPreview — no change.

en/de strings added.
2026-06-12 16:27:29 +02:00
Luca c36797db2d fix(email): log all received mail, not just unseen (90-day lookback + dedup)
Cause of 'not all received emails listed': the poller fetched {seen:false}
only, so any message already read in another client was never pulled or logged.

Now the poller scans a LOOKBACK_DAYS (90) window regardless of \Seen via a
cheap envelope-only pass, dedups by message-id against received_emails, and only
downloads + processes (fetchOne source) messages not yet logged — so the
Received tab is complete while each poll stays light. Marks processed messages
seen; re-checks the parsed message-id before insert.
2026-06-12 16:06:40 +02:00
Luca cee692c10a fix(accounting): lock company-expense to company, first-page categorise preview, auto-refresh inbox
#1 Incoming-invoice triage: 'Company expense' (eigener_aufwand) no longer shows
   the event picker — it always books to the company (removed from
   BOOKING_DISPOSITIONS, so categorize sends event_id null).
#2 Auto-refresh: AccountingInboxPage + ReceivedEmailsPanel poll every 30s
   (refetchInterval) so background IMAP ingests appear without a manual reload.
#3 DocumentPreview defaults to the FIRST page (invoice header) for triage/view;
   PayModal opts into the LAST page (Swiss QR-bill) via initialPage='last'.
2026-06-12 15:43:21 +02:00
Luca 9c18dcf377 fix(email): always log incoming mail to received_emails (was lost on insert error)
Symptom: an emailed attachment landed in Incoming invoices but the message
never appeared under Received emails. The attachment is saved BEFORE the
received_emails insert, so any throw there left the audit row unwritten and
silently swallowed.

- coerce a malformed Date: header (Invalid Date) to now — it would otherwise
  throw on the Postgres timestamp insert (most likely root cause)
- isolate each attachment in its own try so one bad file can't skip the audit
- truncate from_address to the column width; persist attachment errors + an
  'error' status so partial failures are visible
- log loudly when the received_emails insert itself fails (no more silent loss)

Self-healing: the stuck message was never marked \Seen, so the next poll
re-processes it and writes the row.
2026-06-12 15:14:16 +02:00
Luca 8a54c6f6b1 fix(email): fail-fast IMAP timeouts + manual 'Check now' poll
- Root cause of the 502s: ImapFlow had no connect timeout, so a wrong host/port
  (e.g. IMAP on an SMTP port) hung the request until the proxy returned 502 with
  no message. Added connectionTimeout/greetingTimeout/socketTimeout + a hard
  connectWithTimeout() race on every IMAP client (detect/test/roundtrip/poll).
- Error routes now return 422 with the underlying reason (was 502, which
  collided with the proxy's own 502 and hid the message).
- New 'Check now' button + POST /incoming-config/poll runs the poller on demand
  (respects the incomingMail flag) and reports disabled/unconfigured/busy or N
  ingested — so 'nothing in Received' is diagnosable without waiting 60s.
- en/de strings
2026-06-12 15:08:12 +02:00
Luca e258472391 fix(email): guard round-trip test when IMAP username isn't an email
The round-trip recipient is imap_user (not hardcoded). Some hosts use a
non-email IMAP login — guard against silently sending to a bogus address:
return a clear 'recipient_not_email' error explaining to use a mailbox whose
username is its email, or test connection + manual send instead.
2026-06-12 14:54:18 +02:00
Luca 04be51a008 feat(email): round-trip test — send via SMTP to the IMAP mailbox and confirm arrival
- emailIntakeService.roundTripTest(): sends a uniquely-tagged email through the
  saved SMTP config to the IMAP mailbox (imap_user), then polls IMAP up to 30s
  for that subject token; deletes the test message on arrival so it never hits
  the accounting inbox. Returns {ok, seconds, recipient} or a typed reason.
- route POST /admin/email/incoming-config/roundtrip (email.send)
- IMAP card: 'Round-trip test' button beside 'Test connection' + Save; toast
  reports recipient + delivery time. Distinct reasons mapped (smtp/imap
  unconfigured, send_failed, not_received→504).
- en/de strings
2026-06-12 14:52:14 +02:00
Luca f017649bd5 feat(email): add 'Test connection' to incoming mail + tidy IMAP label
- emailIntakeService.testConnection(): logs in, opens the configured folder,
  reports message/unread counts (non-destructive). Accepts current form creds
  so it works before saving; masked password falls back to stored.
- route POST /admin/email/incoming-config/test
- IMAP card: 'Test connection' button beside Save; toast shows folder + counts
- capitalize 'IMAP Host' label to match 'SMTP Host'

Note: incoming uses IMAP (receiving) vs outgoing SMTP (sending) — genuinely
different servers/credentials, hence the distinct field set (Folder; no From).
2026-06-12 14:47:14 +02:00
Luca d04a6978e9 fix(email): IMAP Security dropdown matches outgoing — no port in label, manual port
Reverts the auto-fill; drops the (993)/(143) from the Security option labels so
incoming behaves exactly like the outgoing SMTP card (plain SSL/TLS vs
STARTTLS, port set manually).
2026-06-12 14:41:49 +02:00
Luca bd402d2e89 fix(email): IMAP Security dropdown auto-fills the conventional port
Selecting SSL/TLS sets port 993 and STARTTLS/none sets 143, so the port in
the dropdown label is no longer just decoration. A non-standard custom port
(anything other than 993/143/empty) is left untouched.
2026-06-12 14:39:09 +02:00
Luca fb48ba4cb4 fix(email): mark required fields on incoming mail to match outgoing SMTP
The IMAP card was restyled to match SMTP but didn't carry the required-field
markers. Aligned the required set (protocol differences kept):
- red asterisks on Host *, Port *, Username * (SMTP marks Host/Port/From-Email;
  IMAP has no From-Email but always needs a login)
- client-side guard mirroring handleSaveSmtp (block save without host/port/user)
- backend POST /incoming-config now requires imap_user (the poller's
  getImapConfig returns null without it)
- en/de requiredFields string
2026-06-12 12:33:04 +02:00
Luca abb23f01c7 fix(email): match IMAP card to SMTP styling + auto-detect mailbox folders
- IncomingMailConfigCard rebuilt to mirror the outgoing SMTP card: Card
  padding=md, icon inputs (Server/User/Lock), password eye toggle, stacked
  full-width fields, full-width primary Save button
- Folder is now a dropdown auto-populated by a 'Detect' button instead of a
  free-text path: backend emailIntakeService.listFolders() lists IMAP
  mailboxes (POST /admin/email/incoming-config/folders, accepts current form
  creds, masked password falls back to stored); UI auto-selects the inbox
  (special-use) folder
- en/de strings added
2026-06-12 01:11:31 +02:00
Luca 7e0098edcd feat(accounting): Layer A frontend — chart of accounts CRUD + Treuhänder export UI
- ledger.service.ts: accounts/VAT-codes/mappings CRUD + export client
- ChartOfAccountsPage: full CRUD for the Swiss/LI KMU chart + MWST codes,
  category→account mapping, default/system accounts + tax-treatment/rate→VAT
  maps, with a 'guideline only' note
- LedgerExportPage: period + currency + target tool (generic/Banana/bexio)
  → collective-journal CSV download, with accrual-only + Treuhänder disclaimer
- AccountingLayout: 'Treuhänder export' (taxReport flag) + 'Chart of accounts'
  (accounting flag) sub-nav entries; App routes wired
- en/de translations (accounting.taxTreatment.* enum + ledger.* namespace)

de/en authored natively; no machine-translated locales touched here.
2026-06-12 00:48:39 +02:00
Luca 03cc250b47 feat(accounting): Layer A backend — chart of accounts, VAT codes, Treuhänder export
Prepares picpeak to feed a Treuhänder's double-entry software once a user
crosses the CHF ~500k threshold (LI PGR Art. 1045), without becoming an ERP.

- migration 129: ledger_accounts (seeded Swiss/LI KMU-Kontenrahmen) +
  vat_codes (CH/LI MWST 8.1/2.6/3.8/0 + reverse charge), expense_categories
  gains ledger_account_id, app_settings default-account + VAT-map seeds
- ledgerService: full CRUD for accounts + VAT codes + mappings; buildPostings()
  turns revenue invoices + incoming invoices + expenses into accrual
  Buchungssätze (Dr/Cr + VAT code); generic/banana/bexio CSV export
- routes /api/admin/ledger/* (accounting master gated; export also requires
  taxReport); 12 unit tests (posting engine + formatters)

Accrual basis only — payment/bank postings are Layer B. Output is a guideline
(Treuhänder caveat on the UI).
2026-06-11 21:37:48 +02:00
Luca 727bdab6e0 feat(accounting): re-viewable incoming invoices + expense invoiced/paid lifecycle UI
#1 Incoming invoices are re-viewable: extracted a reusable rasterised
DocumentPreview (last page = QR-bill), added a click-to-view ViewModal on
every row, and embedded the preview in the mark-paid dialog.

#2/#3 Expenses ledger:
- invoiced badge (links to the client invoice) + paid toggle (manual,
  independent of invoiced)
- edit until invoiced (ExpenseFormModal now does create + edit; locked
  rows show a Lock chip instead of edit/add-to-invoice)
- 'Add to invoice' action (re-bill via customer picker + markup) and a
  'Mark paid' dialog
- service: Expense gains invoiced/billedInvoiceId/paid/paidAt fields +
  invoiceExpense() and markExpensePaid()

en/de translations added.
2026-06-11 21:19:06 +02:00
Luca 545ef334f4 feat(accounting): tax window shows all costs (incoming invoices + expenses) alongside revenue
Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case:
- taxReportService.getTaxReport now returns a cost side (loadCosts:
  incoming invoices + internal expenses, company- or event-booked,
  schema-guarded) plus a summary (income / costs / result, VAT payable)
- declined/duplicate costs excluded; re-billed costs kept (matching
  re-bill revenue is counted, so the net is correct)
- CSV + PDF exports gain a Costs section and an income/costs/result
  summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine —
  flag for native review)
- frontend tax page renders the summary card, a costs table (company
  vs event), and a 'verify with Treuhänder' disclaimer
- tax-report tests cover the cost aggregation + zeroed summary when the
  accounting tables are absent; adminCrmAuth test enables the accounting
  master flag the route now requires

fr/nl/pt/ru strings are machine-generated and need native review.
2026-06-11 21:09:13 +02:00
Luca 2e8e4a0f86 feat(accounting): expense invoiced/paid lifecycle + edit-until-invoiced; decouple tax report from bills flag
- transformExpense surfaces invoiced (billed_invoice_id), paid
  (supplier_paid), paidAt, paymentMethod, customerAccountId
- updateExpense throws EXPENSE_LOCKED once invoiced (edit until then)
- rebillExpense mints a client invoice line + locks the expense
- markExpensePaid toggles manual paid state
- adminExpenses: POST /:id/invoice (rebill) + POST /:id/paid
- adminTaxReport now gated by accounting master + taxReport sub-flag
  (independent of bills; tax export moved out of CRM into Accounting)
2026-06-11 17:33:35 +02:00
Luca 31867efcb9 fix(accounting): migration 127 must not insert created_at/updated_at into app_settings
The app_settings table (per its migration schema) has no created_at/updated_at
columns — the canonical seed pattern (migration 103) inserts only
setting_key/setting_value/setting_type. Migration 127 wrongly added timestamps,
so the insert threw `SQLITE_ERROR: table app_settings has no column named
created_at` on every run of the migration suite. That broke the backend test
job (cascading through every suite that builds the schema) and the
Postgres-based fresh-install + schema-drift jobs.

Fix: drop the timestamp columns from the insert, matching migration 103.

Verified: full backend jest suite green (67 suites, 736 passed); migration
harness still green.
2026-06-11 16:57:48 +02:00
Luca 31280e1f7a feat(email): incoming mail UI - IMAP config block + Received emails tab
Frontend for the incoming-mail feature.

- Settings -> Email: an "Incoming mail (IMAP)" block under the outgoing SMTP
  settings (same field shape: host/port/security/user/pass/folder), shown only
  when the incomingMail flag is on (IncomingMailConfigCard, self-contained
  load/save).
- A "Received emails" tab next to "Sent emails" (ReceivedEmailsPanel) listing
  the received_emails log with from/subject/received/status + attachment count
  and a link to the incoming-invoices inbox.
- `incomingMail` flag in the frontend (type + context default, standalone) +
  a Communication-section Features card.
- email.service: getIncomingConfig / updateIncomingConfig / listReceived.
- i18n: settings.features.incomingMail, email.incoming, email.received (EN+DE).

Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 16:19:55 +02:00
Luca 5645c304ab feat(email): incoming mail (IMAP) intake - backend + standalone flag
Adds a second mail config (incoming/IMAP) alongside the outgoing SMTP one, a
1-minute poller, and a received-emails log. Standalone `incomingMail` feature
flag (default off).

- deps: imapflow + mailparser (receive-side; picpeak only had nodemailer).
- migration 128: email_configs gains imap_* columns (same shape as smtp_*);
  seed incomingMail flag; new received_emails audit table.
- emailIntakeService: polls the mailbox every 60s when the flag is on AND a
  mailbox is configured (no-op otherwise); parses each unseen message
  (mailparser flattens forwarded/nested attachments), drops PDF/JPEG/PNG into
  the incoming-invoices inbox (inbound_documents, source='email'), logs each
  message in received_emails (dedupe by message-id; duplicate attachments
  caught by the existing SHA-256 guard), marks it \Seen.
- adminEmail: GET/POST /incoming-config (mirrors SMTP config, masks imap_pass,
  SSRF host guard) + GET /received (paginated log).
- server.js starts the poller at boot.

Verified: node -c, require-graph, migration-128 harness (imap columns, flag,
received_emails). Frontend (IMAP block under SMTP + Received tab + flag card)
follows.
2026-06-11 15:54:43 +02:00
Luca 81af4453e7 feat(accounting): event booking via dropdown (Company or an event)
Replaces the Company/Event toggle + numeric Event-ID input with a single
EventBookingSelect dropdown (Company = null, else a specific event, fetched via
eventsService). Used by both the incoming-invoice triage and the expense add
form. Projects stay a separate aggregation of events and are intentionally not
a booking target here.

Verified: tsc --noEmit clean; npm run build green.
2026-06-11 15:17:43 +02:00
Luca 2b7495e4dc feat(accounting): Accounting settings tab (km / per-diem rate, require-proof)
New Settings -> Accounting tab (gated by the accounting flag) to edit the km
rate, per-diem rate and the "require proof for expense" toggle (reads GET /
writes PUT /admin/settings/accounting). Rates are CHF, stored as integer minor
units; carries the "verify with your Treuhaender" disclaimer. Wired into
SettingsPage (TabType, keys, flag-gated nav item, render) + the features barrel.

i18n: settings.accounting.* (EN + DE, DE native).
Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 12:59:36 +02:00
Luca f305541f90 feat(accounting): frontend rework - separate Incoming invoices vs Expenses (stage 2)
Matches the backend split. Incoming invoices and Expenses are now distinct
surfaces with no shared rows.

Incoming invoices (AccountingInboxPage): triage sets the disposition + booking
(event or company) ON the document; "Mark paid" / "Paid" toggle records
supplier payment HERE with the outstanding total shown; re-bill via the
customer picker + markup. PDF preview still rasterised (last page = QR-bill).

Expenses (ExpensesLedgerPage): internal own-costs only. Add form has a Type
dropdown (amount / mileage(km) / per-diem); km/per-diem switch the input to a
quantity + rate (default from accounting settings, per-entry override) with a
live computed amount; optional proof upload (required when the setting says so);
localized category; booked to an event or the company. Proof viewable per row.

Service: reworked to the new endpoints/shapes; categoryLabel() localizes seed
categories (custom stay free-text). i18n: accounting.booking / incoming /
expense / expenseKind / category (EN + DE, DE native).

Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
2026-06-11 12:47:55 +02:00
Luca 5e78fb6475 feat(accounting): backend rework - incoming invoices vs internal expenses (stage 2)
Implements the split decided in review:

Incoming invoices (external) - the inbound_documents row IS the payable:
- categorizeInbound now UPDATES the document (disposition + tax_treatment +
  booking event_id (null=company) + category), no derived expense row, so a
  supplier invoice appears only in the incoming-invoices surface.
- rebillInbound mints the client invoice from the document (base = invoice
  total + markup) and links it on the doc.
- markInboundSupplierPayment records supplier payment ON the incoming invoice
  (mark-paid lives here now).

Expenses (internal) - own costs only:
- createExpense: kind = amount|mileage|per_diem; amount = quantity x rate
  (rate from accounting settings, per-entry override; snapshotted); optional
  proof file; booked to an event or the company; require-proof enforced from
  settings. No supplier payment, always own-cost.
- listExpenses returns internal rows only (inbound_document_id IS NULL).

Routes: per-flag gating (incomingInvoices vs expenses; categories on the
accounting master); supplier-payment + re-bill moved under /inbound/:id/*;
POST/PATCH expenses accept a multipart proof upload; GET /:id/proof streams it
(PDF download-only, image inline). getAccountingSettings reads app_settings.

Verified: node -c, require-graph, 12 unit tests (markup + expense amount/build).
Frontend rework (service + the two UIs + settings tab + category i18n) follows.
2026-06-11 12:38:23 +02:00
Luca c59df52d40 feat(accounting): split Incoming invoices vs Expenses - flags, schema, settings (stage 1)
Foundation for separating external supplier invoices from internal expenses,
per design review. This stage is additive + buildable; the service/route/UI
data rework follows in stage 2.

- Migration 126: incoming invoices own their payable on inbound_documents
  (supplier_paid/at/method/ref + disposition + tax_treatment + booking event_id
  + category_id + re-bill markup/linkage); expenses gain kind (amount/mileage/
  per_diem) + quantity + snapshotted rate_minor. Additive, hasColumn-guarded.
- Migration 127: seed `expenses` feature flag (default off) + accounting
  app_settings (accounting_km_rate_minor=70, accounting_per_diem_rate_minor=0,
  accounting_require_proof=false).
- Backend: `expenses` added to feature-flag known/defaults/dependency (forced
  off when the accounting master is off); new PUT /admin/settings/accounting
  (read via the generic GET /:type).
- Frontend: `expenses` flag (type + context + dependency); Features tab gets an
  Expenses sub-card; the Expenses sub-nav + route now gate on `expenses` (not
  incomingInvoices); AccountingIndex prefers inbox -> expenses -> tax.
- i18n: settings.features.expenses.* (EN + DE).

Verified: node -c; migration 124->126->127 harness (new columns, flag, settings
+ idempotency); en/de JSON valid; npm run build green.
2026-06-11 12:24:53 +02:00
Luca 413a200592 test(accounting): unit tests for expense markup / disposition logic
Covers the silently-regressable money + classification bits of the re-bill
flow (the maintainer's "thin CRM test coverage" concern). Pure functions via a
new expenseService._internal export — no DB, no date-harness pitfalls:

- computeMarkupMinor: percent rounding, flat, none/null.
- resolveMarkup precedence: override > expense clause > none.
- buildExpenseInsert: bad-disposition guard, tax_treatment/status defaults,
  declined -> status+reason, markup field matches type, parked -> status.

11 tests, all green (npx jest expenseService.markup).
2026-06-11 01:04:14 +02:00
Luca 703f72742d feat(accounting): manual "add expense" (no document) on the ledger
Adds an "Add expense" action to the expenses ledger for costs with no inbound
document — mileage, per-diem, a cash receipt, etc.

- accounting.service: createExpense() -> POST /admin/expenses
  (createManualExpense); CategorizePayload gains `description`.
- ExpensesLedgerPage: AddExpenseModal with supplier / description / amount /
  currency / disposition (company expense / pass-through / re-bill — no
  duplicate, there's no document to dedupe). Company-expense picks a category;
  re-bill uses the customer picker + markup and chains createExpense -> rebill
  into an editable scheduled invoice, same as inbox triage. "Add expense"
  button in the filter row.
- i18n: accounting.ledger.{addExpense,addTitle,description,descriptionHint,
  createdToast} (EN + DE); shared field labels reuse accounting.inbox.field.*.

Verified: en/de JSON valid; npm run build green.
2026-06-11 01:01:40 +02:00
Luca e111522415 feat(accounting): rasterise inbound PDFs server-side (never serve raw to browser)
Security hardening for inbound supplier-invoice previews. The admin UI no
longer renders raw PDFs — a malicious inbound PDF could otherwise run embedded
JS or phone home in the admin's session. Instead PDFs are rasterised to flat
PNGs server-side and only those images are shown.

- backend: new rasterizeService shells out to poppler `pdftoppm` (added to the
  Docker image via apk poppler-utils — an OS package, NOT a Node PDF lib, so it
  respects the pdfkit+pdf-lib "no third PDF lib" rule). pdftoppm executes no JS
  and fetches no remote resources, so it doubles as the SSRF/phone-home guard.
  Rendered pages cached under storage/business-docs/inbound/rendered/<id>/.
  - GET /inbound/:id/page/:n streams the rasterised PNG (CSP default-src 'none'
    + nosniff). GET /inbound/:id/file now serves PDFs as a DOWNLOAD only
    (Content-Disposition: attachment) — never inline; images still inline.
- frontend: triage preview switched from a raw-PDF <iframe> to rasterised page
  images (getInboundPageBlob), defaulting to the LAST page (QR-bill) with
  prev/next nav for multi-page PDFs; images stream as before.
- i18n: previewError / prevPage / nextPage / pageOf (EN + DE).

REQUIRES A BACKEND IMAGE REBUILD (Dockerfile adds poppler-utils) — a plain
`docker compose pull` of a stale image won't have pdftoppm; the route then
returns 503 RASTERIZER_UNAVAILABLE and the UI shows "preview unavailable".

Verified: node -c, a pdfkit->pdftoppm rasterise smoke test (renders + caches),
en/de JSON valid, npm run build green.
2026-06-11 00:51:10 +02:00
Luca 0c35ac43e6 feat(accounting): expenses ledger + supplier-payment toggle
Adds Accounting → Expenses, the view of everything triaged out of the inbox:

- ExpensesLedgerPage: filter by status / disposition; each row shows the
  disposition + status badge, CHF amount, created date, and a link to the
  client invoice for re-billed items. Supplier-payment toggle ("Mark paid" ->
  method + date + reference modal; "Paid" -> click to revert) wired to
  /:id/supplier-payment. Payment status is decoupled from categorisation, per
  the locked design; declined/duplicate rows skip the toggle.
- AccountingLayout: "Expenses" sub-nav item (gated by incomingInvoices).
- App.tsx: /admin/accounting/expenses route.
- i18n: accounting.subnav.expenses, accounting.ledger/expenseStatus/
  paymentMethod (EN + DE, DE authored natively).

Verified: en/de JSON valid; npm run build green.
2026-06-11 00:42:46 +02:00
Luca 502fbad5a8 feat(accounting): PDF/image preview in triage, opened at the QR-bill (no OCR)
Instead of OCR, let the admin read the payment slip directly: the triage modal
now embeds the captured document and, for PDFs, opens at the LAST page scrolled
to the Swiss QR-bill area so IBAN/amount/reference are visible while typing.

- backend: capture PDF page count at upload via pdf-lib (new
  inbound_documents.page_count, added to in-flight migration 124); new
  GET /api/admin/expenses/inbound/:id/file streams the stored file inline
  (safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own
  uploads); the hardened rasterise-in-isolated-worker path stays a follow-up.
- frontend: getInboundFileBlob fetches the file with Bearer auth as a blob;
  the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300,
  <img> for camera photos) in a two-column layout next to the form.
- i18n: accounting.inbox.previewLoading / qrHint (EN + DE).

Verified: node -c, require-graph, migration-124 harness (page_count), npm run
build green.
2026-06-11 00:38:37 +02:00
Luca 2b5efebaff feat(accounting): incoming-invoices inbox with camera capture + triage/re-bill
Adds the Accounting → Incoming invoices frontend on top of the existing
/api/admin/expenses backend:

- accounting.service.ts: typed client (inbound upload/list/get/update/
  categorize, expense list, re-bill, supplier-payment, categories).
- AccountingInboxPage: capture a supplier invoice via the device CAMERA
  (<input accept="image/*" capture="environment">) or a PDF/image upload;
  inbox list with status badges + parsed summary; a triage modal to confirm
  fields and pick a disposition (re-bill / pass-through / company expense /
  duplicate / declined). Re-bill uses the customer picker and mints an
  editable scheduled invoice (chains categorize -> rebill).
- AccountingLayout: "Incoming invoices" sub-nav item + AccountingIndex that
  redirects /admin/accounting to the first enabled sub-feature.
- App.tsx: /admin/accounting/inbox route (gated by incomingInvoices).
- i18n: accounting.inbox/disposition/markup + subnav.incomingInvoices +
  common.saving (EN + DE, DE authored natively).

Camera capture needs no native app — the mobile web input drives the device
camera straight into the upload endpoint. OCR/QR auto-extraction is still a
backend follow-up (extractionService is a no-op), so fields are confirmed
manually in the triage modal for now.

Verified: npm run build green; en/de JSON valid.
2026-06-11 00:31:05 +02:00
Luca 2c351bf0c9 refactor(accounting): make Accounting a master flag with sub-toggles
Replaces the earlier peer-`accounting` flag (which only *conditionally*
relocated Tax) with a cleaner top-level master + sub-toggle model, per design
discussion:

- `accounting` = explicit top-level MASTER (Settings -> Features). Off hides
  the whole Accounting section.
- Sub-toggles, gated under the master:
  - `taxReport` ("Tax export") moves PERMANENTLY out of CRM. Removed from the
    Clients sub-nav and from the derived `clients` flag. Now INDEPENDENT of
    Bills (per decision). Old /admin/clients/tax-report -> redirect to
    /admin/accounting/tax-report.
  - `incomingInvoices` (new) gates the supplier-invoice capture / expenses /
    re-bill feature; the /api/admin/expenses router now checks it.
- Dependency rules (backend + frontend): accounting off forces taxReport +
  incomingInvoices off; taxReport dropped from the clients derivation; the
  bills->taxReport rule removed.
- Preserve visuals: migration 122 rewritten to auto-enable `accounting` on
  installs that already had Tax on (so the tab doesn't vanish), and to seed
  `incomingInvoices` off. Verified with a SQLite harness (taxReport on ->
  accounting on; off -> off).
- Settings -> Features: new "Accounting" section with the master card + Tax
  export + Incoming invoices sub-cards (disabled until the master is on).
- i18n: navigation.accounting, accounting.*, settings.features.{accounting,
  incomingInvoices,taxReport.requiresAccounting}, sections.accounting (EN + DE,
  DE authored natively); Tax report relabelled "Tax export"/"Steuerexport".

Verified: node -c, migration-122 harness, en/de JSON valid, npm run build green.
2026-06-11 00:17:44 +02:00
Luca 30c0007f40 feat(accounting): Accounting nav section + relocate Tax report out of CRM
Adds the `accounting` feature flag to the frontend (type, context default) and
a Settings -> Features toggle card. When enabled:

- A new top-level "Accounting" sidebar entry appears (gated by `accounting` +
  accounting.view), with an AccountingLayout sub-nav mirroring ClientsLayout.
- The Tax report relocates: it is HIDDEN from the CRM (Clients) sub-nav and
  shown under Accounting instead, at /admin/accounting/tax-report. When
  accounting is OFF, Tax stays under CRM exactly as before.

Tax visibility still depends on `taxReport` (which depends on `bills`), so the
relocation only changes WHERE the menu item lives, not whether it exists.

Files: featureFlags.service.ts (+'accounting'), FeatureFlagsContext default,
AdminSidebar entry, new AccountingLayout, ClientsLayout filter, App.tsx route,
FeaturesTab card, en/de i18n (navigation.accounting, accounting.*,
settings.features.accounting; DE authored natively).

Verified: `npm run build` green; en/de JSON valid.
2026-06-11 00:04:40 +02:00
Luca c305492845 feat(accounting): inbound supplier-invoice capture + expense re-bill (backend)
New top-level Accounting area (gated by an `accounting` feature flag, default
OFF, + accounting.view/manage permissions), separate from CRM. Lets an admin
capture a received supplier invoice (upload OR phone/tablet camera), give it a
disposition, and re-bill the cost to a client onto the relevant event's
invoice with a contract-driven markup. Mirrors the billable-hours model.

Backend foundation only — frontend pages (inbox / expenses UI + camera widget)
and the heavy extractors (Tesseract OCR / Swiss-QR decode / isolated rasterise
worker) are follow-ups; extractionService is scaffolded so the upload path is
already wired.

Migrations 122-125 (numbered above the in-flight feat/crm 117-121):
- 122 seed `accounting` flag (default OFF, idempotent)
- 123 seed accounting.view/manage permissions + grant super_admin/admin
- 124 inbound_documents + expenses + expense_categories (+ seed categories)
- 125 contracts Spesen-Zuschlag clause (expense_markup_type/_percent/_flat_minor)

API: /api/admin/expenses — inbound capture/list/confirm/categorize, expense
CRUD, /:id/rebill (event-scoped; markup = expense override -> contract clause
-> 0%; mints an editable scheduled invoice), /:id/supplier-payment, categories.
adminFeatureFlags KNOWN_FLAGS/DEFAULT_FLAGS gain `accounting`.

Conventions: idempotent hasTable/hasColumn-guarded migrations; money in integer
*_minor; QR amount stored separately + untrusted; requirePermission guards;
camelCase API <-> snake_case columns; multer + 15MB cap for PDF/JPEG/PNG.
VAT/tax handling is v1 capture-only — verify with a Treuhaender before relying.

Verified: node -c all files, require-graph smoke test, and a SQLite migration
harness (schema + seeds + idempotency + defaults assert green).
2026-06-11 00:04:16 +02:00
Paul Nothaft c1c5ac726c Merge pull request #615 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.6-beta.0
2026-06-10 18:43:48 +02:00
github-actions[bot] 3aaddaf210 chore(beta): release 3.60.6-beta.0 2026-06-10 16:38:01 +00:00
Paul Nothaft 40a4aa2d85 Merge pull request #614 from the-luap/fix/guest-upload-limits-613
fix(gallery): guest upload honours general_max_files_per_upload + i18n placeholder interpolates (#613)
2026-06-10 18:37:30 +02:00
Paul Nothaft 69b5186582 fix(gallery): guest upload honours general_max_files_per_upload + i18n placeholder interpolates (#613)
Zszywany reported on v3.44.0 (Ubuntu, Postgres 15): with Settings →
General → "Max Files per Upload" set to 10, an event configured with
allow_user_uploads + a guest selecting 16 files in the gallery's
"Upload Photos" modal succeeded silently — admin uploads to the same
event correctly refused with "Upload limit reached". On top of that,
the gallery modal's "fileRequirements" hint literally rendered
`{{limit}}` instead of the configured number.

Two separate misses for the guest path, both fixed here:

1. **Backend enforcement** — `backend/src/routes/gallery.js:1641` had
   `limits: { fileSize: 50MB, files: 10 }` and `.array('photos', 10)`
   hardcoded. The admin path at adminPhotos.js:131 has always resolved
   files-per-batch via `getMaxFilesPerUpload()` (cached 60s read of
   `general_max_files_per_upload`); guest path just never used it.
   Mirror the admin: `const maxFilesPerUpload = await getMaxFilesPerUpload()`
   and feed multer both `limits.files` AND the `.array(...)` cap. The
   50MB per-file size is a separate concern from this issue and stays
   as-is for now.

2. **i18n interpolation missing on the guest modal** —
   `UserPhotoUpload.tsx:203` called `t('upload.fileRequirements')` with
   no arguments. The translation string at `en.json:160` is
   "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)"
   — `{{limit}}` is unbound, so i18next emits it literally. The admin
   variant `PhotoUpload.tsx:414` correctly passes
   `{ limit: maxFilesPerUpload }`.

   Also wired up the same client-side count guard the admin component
   uses: addFiles refuses additions past the limit (`upload.limitReached`)
   and warns on partial-truncate (`upload.someFilesSkipped`). Backend
   enforces too, but the client guard saves a 4MB+ multipart POST when
   the user is clearly over.

To surface the setting on the guest side, `general_max_files_per_upload`
joins the public-settings whitelist + projection (publicSettings.js)
and the `PublicSettings` TS interface gets the new field. Default
fallback (500, matching `uploadSettings.js` DEFAULT_MAX_FILES_PER_UPLOAD)
in both backend projection and frontend reader so an install that's
never set the value renders a sensible number rather than "undefined".
2026-06-10 18:22:34 +02:00
Paul Nothaft 1d03a670e4 Merge pull request #612 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.5-beta.0
2026-06-09 18:11:43 +02:00
github-actions[bot] a3e5ba4e75 chore(beta): release 3.60.5-beta.0 2026-06-09 16:11:18 +00:00
Paul Nothaft 284680e035 Merge pull request #611 from the-luap/fix/delete-cascade-orphan-folders
fix(admin/events): delete cascade orphaned photo folders because it read a non-existent column (#608)
2026-06-09 18:10:52 +02:00
Paul Nothaft 457c956386 fix(admin/events): delete cascade orphaned photo folders because it read a non-existent column (#608)
jodrmx reported on v3.44.0 (Pi Lite, Docker compose): admin-UI event
delete removes the DB row but leaves `storage/events/active/<event>/`
intact on disk.

Root cause: `deleteEventCascade` in adminEvents.js read
`event.folder_path` and gated the `fs.rm` on it. That column is NEVER
WRITTEN anywhere in the codebase — grep confirms two reads in this one
function, zero writes elsewhere. So `event.folder_path` was always
undefined, `if (event.folder_path)` always false, and the per-folder
cleanup silently no-op'd for every delete. The DB-cascade transaction
ran fine, so the symptom was always "row gone, files stay" — exactly
what jodrmx hit.

The actual on-disk location is `events/active/{slug}` everywhere else
in the codebase:
  - adminPhotos.js:260 — `path.posix.join('events/active', event.slug)`
  - adminEvents.js:610, events.js:155, adminThumbnails.js:153 — read
    from `events/active/{slug}`
  - adminArchives.js:171 — reads from same root
  - photoResolver.js:14-15 — documents the layout
The delete cascade was the only path looking at the non-existent column.

Cure: drop the `if (event.folder_path)` guard, read `event.slug`
instead, and remove from both `events/active/{slug}` (active gallery
folder) and `events/archived/{slug}` (the post-archive copy that
survives the archive flow). `event.slug` is NOT NULL and slugify-
sanitized (lower-case ASCII + dashes only via utils/slug.js), so the
path is well-formed and path-traversal-safe. Best-effort `fs.rm`
semantics + try/catch unchanged — failures still log a warning rather
than unwinding the DB transaction, since orphan files are recoverable
noise compared to a half-deleted DB row.

Forward fix only — does not retroactively clean up the orphans that
have accumulated on existing installs. Admins can `rm -rf
storage/events/active/<old-slug>` manually for those; not worth a
migration script for a one-time deploy ritual.
2026-06-08 23:01:32 +02:00
Paul Nothaft fe0d369836 Merge pull request #610 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.4-beta.0
2026-06-08 22:53:54 +02:00
github-actions[bot] 6ccac348ac chore(beta): release 3.60.4-beta.0 2026-06-08 20:52:58 +00:00
Paul Nothaft fcd3ca36c6 Merge pull request #609 from the-luap/fix/admin-header-img-fallback-perm-skeleton
fix(admin): logo-img fallback + sidebar perm hydration + filename NFD transliteration (#523 follow-up 2, #607)
2026-06-08 22:52:37 +02:00
Paul Nothaft 620163f2db fix(downloads): transliterate accented characters in filename via NFD instead of dropping them (#607)
patchingfailed reported on v3.44 stable: a gallery named `Ägypten` with
photo `Ägypten_individual_0050.jpg` downloads as `gypten_...` —
the leading umlaut is dropped entirely. Their hypothesis was a
Content-Disposition encoding issue, but the actual root cause sits
one layer earlier: at UPLOAD time when `generatePhotoFilename` calls
`sanitizeFilename`.

`sanitizeFilename` did:
  String(str).trim()
    .replace(/\s+/g, '_')
    .replace(/[^a-zA-Z0-9_\-\.]/g, '')   // ← drops `Ä` outright
    .replace(/[_\-]{2,}/g, '_')
    .replace(/^[_\-]+|[_\-]+$/g, '');    // ← would strip a leading _ too

For `Ägypten`: alphanumeric-strip → `gypten` (Ä gone, no underscore
left behind because the regex used '' as the replacement, not '_'). The
result is stored in `photos.filename` and that's what downloads serve.
By that point `buildContentDisposition` is doing the right thing
(emits both `filename="..."` ASCII fallback AND RFC 5987
`filename*=UTF-8''…` — Chrome correctly picks the UTF-8 form), but the
string it's encoding has already lost the umlaut at the DB layer.

Cure: NFD-normalize + strip combining marks BEFORE the alphanumeric
strip. Same pipeline `utils/slug.js` (#525) already uses for URL
slugs:

  sanitized = sanitized
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '');

Now `Ägypten` → NFD-decomposed `A` + combining diaeresis → strip
combining mark → `Agypten` survives the alphanumeric pass. Filename
and URL slug stay in sync (the URL was already `Agypten`, per
patchingfailed's report — the filename now matches).

Test surface: new `filenameSanitizer.test.js` pins:
- the headline #607 contract for German / Portuguese / French / Spanish
  accented inputs (with a counter-example using the pre-fix pipeline so
  a future edit can't quietly regress it)
- ASCII-input parity — pre-#607 byte-identical output for every
  pre-existing ASCII case
- `generatePhotoFilename` composed round-trip
- `sanitizeForContentDisposition` + `buildContentDisposition` RFC 6266
  dual-form output (since the helper sits next to this function and is
  the next thing to break if a refactor goes sideways)
- `sanitizeForZipEntry` path-traversal blocking

31 cases total, all pass.

Bundled into PR #609 since it's a small targeted fix and that PR is
already an admin-UI polish branch with low review weight.
2026-06-08 18:05:46 +02:00
Paul Nothaft f51b9cf8df fix(admin): graceful logo-img fallback + show sidebar widgets during perm hydration (#523 follow-up 2)
Two issues Rekoo-PS hit immediately after upgrading to v3.60.3-beta.0:

1. **Broken logo URL rendered the browser's broken-image icon + alt
   text.** Their `<img src={resolvedLogoUrl}>` had no `onError` handler,
   so a 404 / slow logo URL produced the default broken-image rendering
   — which uses the `alt` attribute (`companyName`) as text. Visually it
   looked like the wordmark span had unexpectedly re-appeared on phone,
   even though the actual `<span>` was correctly hidden by the existing
   `wordmarkVisibilityClass` logic.

   Fix:
   - `useState` tracks `logoLoadError` (first failure) and
     `fallbackLoadError` (second failure). On a configured-URL miss the
     `<img>` swaps to the bundled `/picpeak-kamera-transparent.png`; on
     a second miss the `<img>` is removed from the DOM entirely.
   - `useEffect([resolvedLogoUrl])` resets both flags when the URL
     changes, so a dark-mode toggle that flips `lightLogo ↔ darkLogo`
     gets a fresh attempt instead of being permanently sad.
   - `wordmarkVisibilityClass` now derives from `logoEffectivelyVisible`
     (showLogo && !fallbackLoadError) — when both the configured URL
     AND the bundled fallback have failed, the wordmark un-hides on <sm
     so the phone header isn't completely empty.

2. **Sidebar VersionInfo + StorageInfo vanished during the
   permission-hydration window.** The bottom block was gated on
   `hasPermission('settings.view')` directly, which returns `false`
   while `PermissionsContext.isLoading` is still resolving (a few
   hundred ms right after a deploy when the auth context bootstraps).
   Net effect: the whole "Version / Storage" block was absent on first
   paint, then re-appeared once permissions hydrated — Rekoo-PS read
   that flash as "backend version + storage missing".

   Fix: gate on `permissionsLoading || hasPermission('settings.view')`.
   Optimistic render during hydration; permitted users see the widgets
   immediately (with each widget's own internal loading state), denied
   users still see nothing once the permission state lands as `false`.

Side benefit: the `<img>` fallback chain also covers the broader "logo
hosted on a flaky CDN" case for self-hosters, not just the one-time
post-upgrade asset-cache hiccup. Pure resilience polish — no behaviour
change when everything works.
2026-06-08 17:38:01 +02:00
Luca a702f33004 feat(projects): linking a quote/contract cascades the whole deal into the project
linkDealToProject(dealUuid, projectId): links every quote + contract sharing
the deal_uuid, re-points the events the deal converted into (so their
invoices/emails/gallery roll up), and adopts the deal's customer onto an
empty project. Invoked from the assign endpoints AND the quote/contract
editors' project picker (create + update). Drop a quote on an empty project
and its linked contract, event and invoices populate the cockpit automatically.

Verified on a booted DB: assignQuote on an empty project propagates project_id
to the contract + event, adopts the customer, and the overview rolls up all
four document types.
2026-06-07 01:03:27 +02:00
Luca 89bfb6c519 fix(projects): scope 'book to project' to the current customer
A project has at most one customer, so the hours picker shouldn't offer
other customers' projects. HoursSection now passes customerAccountId to
ProjectSelect (shows this customer's projects + still-unassigned ones).
Backend createEntry rejects a projectId owned by a different customer
(422 PROJECT_CUSTOMER_MISMATCH) as defence-in-depth behind the picker.
2026-06-07 00:58:44 +02:00
Luca 0cc52f3693 fix(projects): wrap long URLs in email preview (no horizontal scroll)
A long unbreakable token (e.g. the gallery link) overflowed the email
container and forced the admin to side-scroll. The preview HTML prep now
also injects overflow-wrap:break-word so long words/URLs wrap within the
container. break-word only triggers on overflow, so table layout is
unaffected. (Renamed neutralizeLinks → preparePreviewHtml.)
2026-06-07 00:21:26 +02:00
Luca f71243e388 fix(projects): use real events.edit permission for project writes
The project create/update/assign routes required 'events.manage', which is
not a real permission (the event perms are view/create/edit/delete/archive).
Since it's absent from the permissions table, even super_admin's all-perms
set excluded it, so every write 403'd with 'Insufficient permissions'.
Switched the write routes to the existing 'events.edit'. (Reads keep
events.view; cockpit doc gating + email actions already use real keys.)
2026-06-07 00:10:07 +02:00
Luca fa622cf2f8 fix(projects): make email preview fully read-only (no clickable links)
Inside the preview iframe the Accept button navigated but other links didn't
— inconsistent, and worse, clicking Accept/Decline would hit the live action
URLs and change the quote state. Sandbox the iframe (no popups/scripts/forms)
and force all anchors to target=_blank so every link is inert. Now nothing in
the preview is clickable (consistent + safe); scrolling and brand colors are
unaffected.
2026-06-06 23:45:54 +02:00
Luca b9a9c018c0 fix(projects): render email preview with its own brand colors, not forced light
The email wrapper already sets body/container/text backgrounds from the
brand email-theme settings (email_body_bg_color etc.), so a dark preview is
the configured design — forcing it light was wrong. Render the email as-is;
set the iframe color-scheme to 'normal' only so the admin's dark app theme
doesn't leak into the iframe's UA defaults. The brand's light/dark choice is
respected.
2026-06-06 23:25:29 +02:00
Luca 84b4a5f049 fix(projects): make the whole email row clickable (opens preview)
Document rows navigate on click, but email rows only had clickable action
buttons — the row itself was dead, which read as inconsistent. The whole
email row now opens the preview; the action buttons stopPropagation so
Resend/Cancel/Retry/Send-now still fire without also opening the preview.
Every actionable feed row is now uniformly clickable.
2026-06-06 23:17:29 +02:00
Luca 2369323259 fix(projects): only link cockpit rows when the target feature is enabled
The cockpit surfaces quotes/invoices/contracts by PERMISSION, but their
detail routes are gated by feature FLAG (RequireFeature). With those flags
off, clicking a row navigated to a route that redirects to /admin/dashboard
— so links 'did nothing' while the email action buttons (plain API calls)
worked. hrefFor now returns null when the destination flag is off, so the
row renders as non-clickable text instead of a dead link. Galleries/events
are never flag-gated, so they always link.
2026-06-06 23:05:49 +02:00
Luca 94f2c01590 feat(projects): flag re-rendered emails in the feed
Each email in the rollup now carries a 'stored' flag (rendered_html present).
Emails without an exact stored copy show an amber '≈ re-rendered' tag next to
Preview, so it's visible at a glance — not just inside the modal. en + de.
2026-06-06 22:50:03 +02:00
Luca f02fba6332 fix(projects): scope email rollup to CRM types + re-render unstored previews
- The customer address often doubles as the admin notification target, so
  matching emails purely by recipient swept in system alerts (backup_failed,
  restore_failed, …). The recipient match is now restricted to CRM document
  types (quote_/contract_/invoice_/storno_); event-scoped mails still match
  by event_id.
- getEmailPreview now falls back to renderQueuedEmail() — re-rendering from
  the current template + the row's stored email_data — for emails sent before
  rendered_html capture, flagged exact:false with an amber 're-rendered' note.
  Only a missing template / no variables falls through to 'nothing stored'.

Known limitation: a customer with multiple projects sees their event_id=null
CRM mails under each (email_queue has no project_id).
2026-06-06 22:47:43 +02:00
Luca c0b6d14d08 fix(projects): clickable milestones/feed, email rollup by customer, PG amount coercion
- Milestones + feed rows now link to the document (quote/contract/bill
  detail, event for galleries); hours have no page so stay non-clickable.
- Email rollup also matches the project customer's address — quote/invoice/
  contract mails are queued with event_id=null, so the by-event scope alone
  showed none (hence 'no email preview'). Now they appear with preview.
- Feed amounts coerce total_amount_minor with Number(): Postgres returns
  bigint as a string, which formatMoneyMinor's Number.isFinite check
  rejected and rendered as CHF 0.00. (computeValuation already coerced.)
2026-06-06 22:28:52 +02:00
Luca 7ca243780a feat(projects): rolled-up project value (newest stage wins per deal, cumulative)
- computeValuation helper: per deal_uuid, the invoice total (installments
  summed, storno netted) wins over the quote; contracts carry no total so
  never contribute. Summed across the project's events, split by currency.
- Value column on the Project Overview list + a value/paid block in the
  cockpit header. Both gated by bills.view/quotes.view so no figure leaks.
- listProjects computes all values in two bulk queries (not per-project).
- en + de i18n; six unit assertions cover the rule's edge cases.
2026-06-06 13:48:46 +02:00
Luca dffcf6269f feat(projects): attach-event control in the cockpit
Search any event by name and attach it to the project (re-points
events.project_id via assignEvent). Lists the project's current events
above the search. en + de i18n. Completes event grouping UX — admins
can now regroup the auto-created per-event projects however they like.
2026-06-06 13:27:43 +02:00
Luca 81553aa0e3 feat(projects): Project Overview cockpit UI + CRM nav entry
- ProjectsListPage: searchable list + inline create, under CRM → Overview.
- ProjectCockpitPage: editable header, milestone timeline, and one dated
  feed merging emails (with sent-HTML preview modal + resend/cancel/retry/
  send-now actions), quotes, contracts, invoices, galleries and hours.
- Routes /admin/clients/projects(/:id) gated by RequireFeature flag=projects.
- ClientsLayout 'Overview' nav entry (top), gated on flags.projects.
- en + de i18n for the projects namespace + book-to-project label.
2026-06-06 13:25:38 +02:00
Luca 0175007abc feat(projects): gated project pickers on quote/contract/hours editors
- ProjectSelect: a reusable picker that renders nothing when the projects
  flag is off (satisfies 'book to project hidden unless projects enabled').
- projects.service.ts: full frontend API client (list/get/create/update,
  overview, assign event/quote/contract, email preview + 4 actions).
- Quote + contract editors carry an optional projectId (state, prefill,
  payload); service payload/detail types updated.
- HoursSection gains a 'book to project' control; backend createEntry
  persists project_id (migration 118, hasColumnCached guarded).
2026-06-06 13:20:59 +02:00
Luca 6420047e7c feat(projects): link quotes & contracts to a project (precise cockpit rollup)
- Migration 121 adds quotes.project_id + contracts.project_id (nullable FK,
  index) and backfills the unambiguous single-project-per-customer case.
- projectService rolls quotes/contracts up by project_id, with a
  customer-based fallback on pre-121 DBs (hasColumnCached guarded).
- quote/contract create+update accept an optional projectId; detail
  transforms surface it for editor prefill.
- POST /projects/:id/quotes and /:id/contracts assign endpoints.
2026-06-06 13:05:36 +02:00
Luca 1bf0b34ea5 feat(projects): gate Project Overview behind a projects feature flag + cockpit email actions
- Migration 120 seeds the projects flag (default OFF), idempotent.
- Backend feature-flags whitelist + DEFAULT_FLAGS + clients derivation.
- adminProjects routes 403 PROJECTS_DISABLED when the flag is off.
- projectService email actions (resend/cancel/retry/send-now) + routes.
- Frontend flag type, DEFAULT_FLAGS, Features tab card (en+de).
2026-06-06 12:59:57 +02:00
Luca 874c91f944 feat(crm): Project Overview phase 3 — persist sent email HTML
processEmailQueue now stores the actual rendered HTML in email_queue
.rendered_html on a successful send (sendTemplateEmail returns it). Guarded
by hasColumnCached so installs without migration 119 just skip it; never
blocks the send. Powers the cockpit's exact-sent email preview.
2026-06-06 04:00:16 +02:00
Luca eb263137b9 feat(crm): Project Overview phase 2 — project service + routes
Backend API for the cockpit (admin-only, Model A):
- projectService: list/get/create/update, assignEvent (re-point events.project_id),
  getProjectOverview (rollup — invoices/emails/gallery by event, quotes/contracts
  by customer since they carry no event_id, hours by project_id, + a milestone
  timeline), getEmailPreview (actual sent HTML).
- adminProjects routes (/api/admin/projects): read=events.view, write=events.manage;
  the overview gates each money-doc type on the admin's own bills/quotes/contracts
  .view permission. Registered in server.js.
All aggregation queries verified against the real schema on a temp DB.
2026-06-06 03:50:55 +02:00
Luca efa47d697d feat(crm): Project Overview phase 1 — projects schema
Data model for the admin-only Project Overview cockpit (Model A — projects
group events; money docs stay per-event and roll up).
- migration 117: projects table (name, customer_account_id nullable, status)
  + events.project_id FK; backfill one auto-project per existing event (1:1
  default, customer = the event's single assignment when unambiguous), admins
  relink freely afterward. 1 project : N events.
- migration 118: customer_hour_entries.project_id (book hours to a project).
- migration 119: email_queue.rendered_html (store actual sent HTML for the
  cockpit's email preview).
All idempotent (hasTable/hasColumn guards), reversible downs. Verified: full
migration boot + backfill on a temp DB.
2026-06-06 03:06:21 +02:00
Luca 43b10f91c0 fix(crm): localize scheduled-send + installment date + timezone picker
From dev testing:
- BillEditor 'Geplanter Versand' was a native <input type=datetime-local> →
  rendered US date + 12h regardless of settings. Split into LocalizedDateInput
  + TimeField (honour general_date_format + general_time_format), recombined
  into the YYYY-MM-DDTHH:MM the payload/scheduler expect.
- InstallmentsPanel 'Send on' native <input type=date> (browser-locale via a
  lang hint, wrong in Safari/Firefox) → LocalizedDateInput, consistent in every
  browser. (Luca approved converting it.)
- Business-profile Timezone was a free-text input → dropdown of the full IANA
  list (Intl.supportedValuesOf, CH/LI fallback), blank = system default.
2026-06-06 02:22:46 +02:00
Luca ea09a86d05 fix(crm): recent-activity email placeholder + customer-dashboard locale dates
- Recent Activity rendered literal {{email}} — the per-row t() call didn't
  pass the email interpolation var. Source it like formatActivityMessage
  (metadata.email ?? actorName).
- Customer 'Deine Galerien' dates rendered en-US ('May','Jun') under a German
  UI because they used raw date-fns format(parseISO(iso),'PP') with no locale.
  Route through useLocalizedDate().format → honours general_date_format + the
  active language.
2026-06-06 00:42:08 +02:00
Luca a2b2d3fb31 fix(crm): PR #603 review follow-ups + Outlook-proof email design
Addresses the maintainer's non-blocking review items + the Outlook email bug:
- invoice create: verify the chosen event belongs to the customer (only when
  the event has assignments; legacy unassigned events pass through).
- mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year
  can't silently drop a payment out of every cash-basis revenue window.
- customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize
  (was isString/max:2 — allowed '', '1', '!@'), matching the business-profile
  route.
- email transporter: close the previous instance before re-init (leak guard
  for a future pooled transport).
- scheduled-email tz: warn loudly when business_hours is set but the profile
  timezone is blank (was silently using the server/UTC tz).
- wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and
  inline the themed CTA button, so the design survives Outlook/Apple Mail
  stripping the head <style> (kept the <style> as progressive enhancement).
2026-06-06 00:42:08 +02:00
Paul Nothaft b7fc86deef Merge pull request #606 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.3-beta.0
2026-06-04 22:10:15 +02:00
github-actions[bot] 13b3a4cfa1 chore(beta): release 3.60.3-beta.0 2026-06-04 20:09:37 +00:00
Paul Nothaft ea074d3102 Merge pull request #603 from Luca-Timo/feat/crm-improvements
CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
2026-06-04 22:09:08 +02:00
Luca 1214b6b762 fix(security): re-apply SVG CSP on the direct favicon route (PR #603 blocker)
The /favicon.ico + /apple-touch-icon routes stream the file directly,
bypassing the secureStatic middleware that locks down served SVGs. An
admin-uploaded SVG favicon with <script> would then run at the top-level
origin (stored XSS). Re-apply the same CSP (default-src 'none') + nosniff
for .svg here, mirroring secureStatic.js. Reported in the #603 review.
2026-06-04 21:49:27 +02:00
Paul Nothaft 859a82a048 Merge pull request #605 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.2-beta.0
2026-06-04 21:42:30 +02:00
github-actions[bot] 6ee30a357d chore(beta): release 3.60.2-beta.0 2026-06-04 19:40:36 +00:00
Paul Nothaft b48b5b0000 Merge pull request #604 from the-luap/fix/header-skeleton-lang-in-profile
fix(admin-header): skeleton brand block + move LanguageSelector into profile menu on <sm (#523 follow-up)
2026-06-04 21:40:04 +02:00
Paul Nothaft fe10191b82 fix(admin-header): skeleton brand block + move LanguageSelector into profile menu on <sm (#523 follow-up)
Two complaints in Rekoo-PS's 3.60.1-beta.0 follow-up screenshots:

1. "Logo took some time to load" — header appeared empty for the
   ~hundreds-of-ms window between admin mount and `usePublicSettings()`
   resolving. The previous code rendered the static fallback
   `/picpeak-kamera-transparent.png` during that window, which often
   either 404'd or loaded after the rest of the chrome, and because the
   wordmark is `hidden sm:inline` whenever a logo is intended to be
   shown, phone-width admins saw an empty left cluster instead of
   anything.

   Cure: render a small pulsing skeleton block (h-8 w-8 on <sm, w-32
   on sm+) while `brandingLoading === true`. Same h-8 footprint as the
   real logo image so there's no layout shift when the real payload
   arrives. Once the public-settings query settles, the normal brand
   block renders against known state.

2. "Moving the languages inside the profile tab" — Rekoo-PS argues
   language is set-once and shouldn't occupy permanent header real
   estate on mobile (4 widgets in the right cluster on phone is
   crowded). I agree.

   On <sm: header LanguageSelector is hidden (`hidden sm:block` wrapper
   around the existing component). A collapsible Language section is
   added at the top of the user-menu dropdown showing the current
   flag/name + chevron-down. Expanding shows the 8 supported languages
   as inline rows highlighting the active one. Picking a language fires
   i18n.changeLanguage and closes the menu.

   On sm+: header LanguageSelector stays where it was. The user-menu
   Language section is suppressed (`sm:hidden`) so the same control
   isn't surfaced twice.

Also: `useOnClickOutside(userMenuRef, …)` and the in-menu action
handlers now route through a shared `closeUserMenu()` helper that
also resets the lang sub-section state, so re-opening the menu
doesn't surprise the user with the language list still expanded.
`SUPPORTED_LANGUAGES` re-exported from `components/common` so
AdminHeader doesn't reach into `LanguageSelector.tsx` directly.

No behaviour change on `sm+` — pure phone-view layout fix +
loading-state polish. Locales unaffected (uses the already-existing
language names from SUPPORTED_LANGUAGES).
2026-06-04 21:18:51 +02:00
Luca fe56d24a6b fix(crm): country dropdown on customer profile billing address too
Mirror the onboarding fix on the customer profile (Rechnungsadresse): replace
the free-text 2-char Country input with the CountrySelect dropdown and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
2026-06-03 21:13:09 +02:00
Luca 18ffff29c3 fix(crm): country dropdown on customer onboarding, placed after State/region
The accept-invite (onboarding) address form used a free-text 2-char Country
input sitting above State/region. Replace it with the CountrySelect dropdown
(same component as the admin customer + business-profile forms) and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
2026-06-03 21:07:59 +02:00
Luca 47edbf64b5 fix(email): surface the real error on test/save/flush instead of generic toast
The test-email, save-config, and flush mutations all showed the generic
'Failed to save changes' toast on error, hiding the actual backend reason —
so a failing test email looked like a save failure and gave no diagnosis.
Show response.data.error / .details (SMTP auth/connection failure, masked
password, private-host rejection, …) with the generic string as fallback.
2026-06-03 19:56:26 +02:00
Luca 68c967f9bb fix(email): recover stuck queue — reinit transporter on config save + manual flush ignores retry cap
Two gaps left emails stuck 'pending' after (re)configuring SMTP:

1. Saving the email config never re-initialised the transporter. The queue
   processor only re-inits when its cached transporter is null, so a changed
   SMTP account had no effect until a backend restart. Now call
   initializeTransporter(true) after save (it self-catches; invalid config
   just leaves it null, surfaced via the Test-email button).

2. The manual 'send now' flush (ignoreSchedule) still enforced retry_count<3,
   so emails that failed 3× while SMTP was broken could never be retried from
   the UI. Move the retry-cap (and schedule gate) to automatic runs only;
   a manual flush forces a retry of every pending email.
2026-06-03 19:40:36 +02:00
Luca a2b5ae17f3 fix(crm): drop redundant 'Country (full name)' field
The customer detail + business profile forms showed both a Country picker
(stores the ISO code) and a free-text 'Country (full name)' override
(migration 107). Now that the picker offers the full ISO list and the PDF
renderer derives the localized full name from the code (pdfService.countryName,
used as 'country_name || derive' for both issuer and recipient), the free-text
field is redundant. Remove the input from both forms. The DB column + the
fallback stay, so any legacy override still renders.
2026-06-03 19:28:47 +02:00
Luca c60e34ecae fix(branding): point HTML favicon link at /favicon.ico (the real Safari fix)
index.html hardcoded <link rel=icon href=/favicon-32x32.png>. When the HTML
declares a favicon link, the browser uses it and NEVER requests /favicon.ico
— so Safari showed the bundled default and our dynamic backend route was
never hit (direct /favicon.ico was correct, but the tab wasn't). DynamicFavicon's
JS swap is exactly what Safari ignores.

Point the link at /favicon.ico (backend dynamic route) + add apple-touch-icon,
no type/sizes so the response content-type wins. Now the configured favicon
shows from first paint in every browser, Safari included.
2026-06-03 19:01:55 +02:00
Luca 7ccfdc1aea fix(branding): stream favicon bytes directly (Safari ignores the 302)
The /favicon.ico route 302-redirected to the uploaded file. Firefox/Chrome
follow that, but Safari does NOT reliably follow a redirect for favicon
requests — it falls back to the HTML <link>, i.e. the bundled picpeak
default. Stream the file bytes directly for local /uploads favicons (with a
path-containment guard); only external URLs and the missing-favicon fallback
still redirect. sendFile sets the content-type from the extension.
2026-06-03 18:35:46 +02:00
Luca 82ec23824a feat(crm): cash-basis revenue + backdatable payment date on mark-paid
Per decision: keep dashboard revenue windows on pure cash basis (recognise
by paid_at for ALL invoices) and give the admin control over paid_at.

- adminDashboard: revert the imported-vs-native split; winSum is paid_at >=
  cutoff for every paid invoice again (clean cash basis).
- BillDetailPage mark-paid dialog: add an optional 'Payment date' field
  (LocalizedDateInput, defaults to today) so a payment can be backdated to
  when it actually arrived. Backend already accepted paidAt end-to-end
  (route validator + markPaid service + payment-log) — only the UI was
  missing. EN/DE 'bills.payment.date' added.

This fixes the collapsed 30=90=365 windows (they were collapsing because
many invoices were marked paid in one session, all stamped 'now').
2026-06-03 18:20:55 +02:00
Luca 0b4690afab fix(crm): commit LocalizedDateInput value live, not only on blur
The historical-invoice import (and any form whose date field has a non-empty
default like today) lost a typed date: the value was only pushed to the parent
on blur, so submitting while the field was focused — or before React
re-rendered after the blur-time setState — sent the stale default. Issued/
event dates came out as 'today' instead of the entered date.

Now commit as soon as a complete, valid date is entered (toIso returns '' for
partial input, so intermediate keystrokes emit nothing); blur still normalises
display + handles clearing. Applies to every LocalizedDateInput consumer.
2026-06-03 17:50:13 +02:00
Luca db3e3270f3 fix(branding): serve favicon via backend route so Safari picks it up
Safari requests /favicon.ico and /apple-touch-icon*.png at the site root and
is unreliable about honouring JS-injected <link rel=icon>, so an admin-set
favicon never showed there (index.html only ships /favicon-32x32.png; a bare
/favicon.ico 404'd).

- Backend: GET /favicon.ico + /apple-touch-icon(.png|-precomposed.png) resolve
  the configured branding_favicon_url (redirect to its /uploads path or the
  absolute URL), falling back to the bundled /favicon-32x32.png.
- nginx: exact-match (=) locations proxy those paths to the backend, winning
  over the static-asset regex that previously served them from the build dir.
- DynamicFavicon also emits an apple-touch-icon link (belt-and-braces).

Requires a frontend image REBUILD (nginx.conf change) in addition to backend.
2026-06-03 17:35:32 +02:00
Luca 5e79c69cda fix(crm): recognise imported-invoice revenue on issue_date, not paid_at
The dashboard revenue windows (30/90/365 days) keyed purely on paid_at.
Imported historical invoices therefore landed in the recent window whenever
their paid_at sat there — notably legacy rows imported before commit c6b8cc9
began anchoring an import's paid_at to its issue_date, which still carry an
import-time paid_at. Recognise imported invoices (imported_pdf_path NOT NULL)
on their issue_date instead; native invoices keep cash-basis paid_at. No data
migration needed — fixes already-imported year-old invoices too.
2026-06-03 17:02:50 +02:00
Luca 0b4b5cf46e fix(branding): theme-aware logo across all login / auth entry pages
Extends the dark-logo fix to the customer login, customer accept-invite,
customer reset-password, and gallery client-access pages — they all rendered
only the light logo on the themed (possibly dark) surface.

Also makes the login-page pick frame-aware: a framed login logo sits on a
fixed cream plate, so the light (dark-ink) logo always reads there; only the
frameless logo sits on the themed page background and uses the dark variant.
This corrects the admin login too (was unconditionally swapping when dark).

Customer/gallery pages read isDark from usePublicDarkMode (branding_force_
color_mode + OS fallback), matching CustomerLayout.
2026-06-03 16:56:15 +02:00
Luca 05c1e8d18b fix(branding): theme-aware logo on customer-facing public pages
The public quote, contract-signing, and payment-check pages baked a single
light logo (the contract page showed none), so the dark page rendered a
dark-text logo on a dark background.

- usePublicDarkMode now returns { isDark } (reactive) alongside applying
  the .dark class, so pages can pick a theme-aware asset.
- The three public routes now surface both branding logo URLs (logoUrl +
  logoUrlDark) in the issuer block; the contract issuer gains a logo too.
- QuoteResponsePage, ContractResponsePage, and the payment-check
  BrandingHeader pick the dark variant when isDark, falling back to
  whichever exists. Covers the accept/accepted states of each page.
2026-06-03 16:48:54 +02:00
Luca a5011b1ea2 fix(pdf): version the logo rasterisation cache so the font fix takes effect
The SVG->PNG cache was keyed only by source path + mtime + size, so an
override logo rasterised once WITHOUT fonts (text -> tofu) stayed cached
after the font fix - the source SVG was unchanged, so the stale tofu PNG
kept being served. Add a RASTER_VERSION component to the cache key; bumping
it (v2-fonts) invalidates every prior rasterisation without clearing the
cache dir by hand.
2026-06-03 16:34:38 +02:00
Luca 12591556a0 fix(branding): accept SVG favicons
The favicon upload allowed only PNG/ICO, so an SVG favicon was rejected.
Accept image/svg+xml (.svg) too - DynamicFavicon already emits the right
MIME type and served SVGs are CSP-locked (render-only) by secureStatic.
Update the EN/DE help text accordingly.
2026-06-03 16:34:38 +02:00
Luca 307b84fe05 fix(branding): use dark-mode logo on the admin login page
The login page only ever rendered branding_logo_url (the light logo), so a
dark-text logo sat on the dark background in dark mode. Pick the dark
variant via useAdminDarkMode (honouring branding_force_color_mode too),
mirroring AdminHeader/AdminSidebar, with a fallback to whichever exists.
2026-06-03 16:34:38 +02:00
Luca 9454f43eba fix(pdf): install fonts so SVG logo text rasterises correctly
The runtime image (node:22-alpine) shipped without any fonts, so when
sharp/librsvg rasterised an SVG logo containing live <text> for the CRM
PDFs, the vector artwork drew but the text rendered as tofu boxes - a
'corrupted' logo on invoices/quotes.

- Add fontconfig + DejaVu/Liberation (broad Unicode fallback) and refresh
  the font cache.
- Register picpeak's own bundled brand fonts (assets/fonts/<Family>/*.ttf -
  the same files PDFKit and the web UI already use) with fontconfig via a
  conf.d <dir> entry + fc-cache, so the logo's text renders in its ACTUAL
  brand typeface rather than a generic fallback.
2026-06-03 16:07:29 +02:00
Luca 11257a7936 fix(branding): allow larger square favicons
The upload never enforced 32x32 - only the help text recommended it,
which misled admins. Update the EN/DE guidance to recommend a larger
square image (512x512) and raise the favicon upload cap 1MB -> 2MB so
high-resolution PNGs fit comfortably.
2026-06-03 16:01:07 +02:00
Luca 7b36582ef5 fix(crm): make 'Configure defaults in Settings' link navigate in-app
The link was an <a target="_blank"> doing a hard SPA boot in a new tab,
which tripped the error boundary. Switch to a react-router <Link> so it
opens Settings -> CRM the same way the sidebar nav does (known-good path).
2026-06-03 16:01:07 +02:00
Luca 615ef757d3 fix(crm): offer full ISO 3166-1 country list in pickers
The country dropdown was a curated 22-entry European subset; expand it to
the complete ISO 3166-1 alpha-2 set so customers from any country can be
selected. Labels are still derived from Intl.DisplayNames and sorted by
localized name at render time, so no translation map is needed.
2026-06-03 16:01:07 +02:00
Luca 4d3da35168 fix(branding): dark logo in the Branding live preview
GalleryPreview used only branding.logo_url, so the Live Preview kept the
light logo when previewing a dark theme. Pick the logo by theme.colorMode
(symmetric fallback) and pass logo_url_dark through from BrandingPage.
2026-06-03 15:30:16 +02:00
Luca 7d34c97c58 fix(branding): dark-mode logo in the admin sidebar
The sidebar brand row (logo_position=sidepanel) + collapsed rail used
the light logo unconditionally. Make it theme-aware via useAdminDarkMode
with the same symmetric fallback as the header (dark uses dark||light,
light uses light||dark). This was the missing admin surface — the header
already switched.
2026-06-03 15:19:08 +02:00
Luca e8960a41cf i18n(contracts): German translations for the contract detail page
The whole contracts.detail.* namespace was English-fallback-only, so the
contract detail page rendered English in German. Add all 64 keys to en +
de (native German), covering actions, signing/counter-sign, audit trail,
convert, and PDF flows. Also dedupe a duplicate events.createInvoice key
(identical value).
2026-06-03 14:49:22 +02:00
Luca 807ae3d4fa fix(security): block script execution in served SVGs via CSP
Serve uploaded SVGs (admin logos etc.) with a restrictive
Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline';
img-src 'self' data:) + X-Content-Type-Options: nosniff in secureStatic.
The browser still renders the vector, but any embedded <script>/on*
handler can't execute if the SVG is opened directly — keeps real SVGs
(scalable) instead of rasterising them. Applies to all secureStatic
mounts (uploads/photos/thumbnails/fonts); only SVGs get the header.
2026-06-03 14:37:40 +02:00
Luca 02742b3163 feat(hours): open the hours invoice in the editor to add items
After 'Create draft invoice' mints the single scheduled invoice from a
per-event customer's unbilled hours, navigate to the bill editor so the
admin can add other line items before it ships (invoice is already
status='scheduled' + editable). Updated the per-event hint copy.
2026-06-03 14:20:49 +02:00
Luca 5d7b545bf7 feat(admin): System health page surfacing stuck/failed emails
New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
2026-06-03 13:56:08 +02:00
Luca a6e6ef7b83 fix(branding): SVG (and .ico) favicons now render
DynamicFavicon hardcoded link.type='image/png', so an SVG/.ico favicon
was declared as PNG and browsers ignored it. Derive the type from the
file extension instead. (Sidebar icon already uses <img> which renders
SVG fine.)
2026-06-03 13:38:52 +02:00
Luca 44590b8c0b feat(branding): symmetric light/dark logo fallback + customer surface
Pick the logo by the active color mode with a symmetric fallback: a
single uploaded logo serves both modes (dark uses dark||light, light
uses light||dark). Apply to admin header, customer gallery, and the
customer portal (follows branding_force_color_mode). PDFs already use
the light branding logo with the business-profile PDF logo as override
(resolveLogoFile) — unchanged.
2026-06-03 13:33:27 +02:00
Luca 4790ea5ccd feat(branding): dark-mode logo variant
Add an optional dark-mode logo (branding_logo_url_dark) alongside the
main logo. Upload/remove via the logo endpoint (?variant=dark) on the
Branding settings page. Admin header (admin dark mode) and the public
gallery (dark themes) pick the dark logo when active, falling back to
the light logo when unset. PDFs keep using the light logo.
2026-06-03 13:26:56 +02:00
Luca d7f0488f6e feat(crm): cross-link to CRM settings from quote + invoice editors
Add a 'Configure defaults in Settings' link (opens Settings → CRM in a
new tab) under the payment-conditions section of the quote and invoice
editors, so the admin can jump to the payment-term / Skonto / numbering
defaults without hunting for the settings page.
2026-06-03 13:12:28 +02:00
Luca 7eec1337da feat(contracts): preview PDF before sending
Add a 'Preview PDF' button on draft contracts that renders a fresh PDF
via the existing no-write /preview endpoint, so the admin can check
layout + signature blocks before sending (no audit trail created).
2026-06-03 13:10:06 +02:00
Luca f3a6c8940a fix(date-input): render pg full-ISO dates in the configured format
LocalizedDateInput.toDisplay only matched a bare yyyy-MM-dd, but Postgres
serializes DATE columns as a full ISO datetime, so the field printed the
raw "2026-…T…Z" string (SQLite returned a bare date, hiding it). Match the
leading yyyy-MM-dd of any ISO value and slice the hidden native picker's
value to 10 chars. Fixes ISO-form dates on customer/event/passive-create.
2026-06-03 11:23:58 +02:00
Luca a03146edc9 feat(events): "Create invoice" action on the event detail page
Add a bills-gated button that opens the bill editor pre-filled with the
event (eventId FK + name/date snapshot) and the linked customer (when
exactly one). BillEditorPage gains eventId state + query-param prefill +
sends eventId on create; backend validates eventId (already forwarded +
persisted). Reuses the editor — no empty drafts. Does not auto-pull hours.
2026-06-03 00:07:00 +02:00
Luca b378ad679f feat(email): hold relationship mail to business hours
queueEmail gains options.respectBusinessHours: snaps the send time to the
next open business-hours block (from now), only deferring when it actually
falls outside hours. Applied to dunning reminders + gallery-expiry warnings;
transactional/admin-initiated mail stays immediate. No-op until business
hours are configured.
2026-06-02 19:44:04 +02:00
Luca 626ab45e0b refactor(time): app-wide setting-aware TimeField for all time inputs
Add shared components/common/TimeField (displays per general_time_format,
stores canonical HH:MM, parses tolerant free-text, browser-independent)
and migrate every native <input type="time"> to it: business hours,
HoursSection, HourEntryInlinePopover, CreateEventPage, Quote/Bill/Contract
editors. Removes the unreliable lang-hint plumbing.
2026-06-02 17:24:27 +02:00
Luca 2149f38cd1 fix(business-profile): setting-aware custom time field for business hours
Native <input type="time"> ignores general_time_format (browser-locale
controlled; lang hint failed in Safari and Ralf's Chrome for both en-GB
and de-DE). Replace with a custom TimeField text input that displays per
general_time_format (24h "13:00" / 12h "01:00 PM") and stores canonical
HH:MM, parsing tolerant free-text on blur. Keeps the fixed-width
alignment fix.
2026-06-02 16:57:08 +02:00
Luca e1d9d06ae9 fix(business-profile): use the app-standard de-DE lang hint for 24h time
Business-hours time pickers used lang="en-GB", which didn't render 24h in
Chrome. Switch to lang={timeFormat==='12h'?'en-US':'de-DE'} — the same hint
HoursSection/Quote/Bill/Contract editors use — so the picker shows 24h in
Chrome/Edge. Keep the fixed-width plain <input> for column alignment.
2026-06-02 16:36:46 +02:00
Luca f4af290fa0 fix(business-profile): align business-hours time columns
The shared Input wraps fields in a w-full div, so two per flex row split
the width and the trailing +/trash buttons knocked columns out of
alignment. Use a plain fixed-width <input> for the start/end time fields.
2026-06-02 15:16:52 +02:00
Luca 2ef837680e fix(i18n): honor general_time_format across all time displays
Route 10 surfaces that hardcoded 12-hour date-fns patterns
('h:mm a', 'PPp', 'p', toLocaleTimeString) through useLocalizedDate's
formatDateTime/formatTime so they respect general_date_format +
general_time_format: backup/restore, archives, photo viewer, feedback,
event details, gallery timeline, public quote page, CMS save indicator.
Also pin a lang hint on the business-hours native time inputs (Chrome/Edge
render 24h). Drops now-unused date-fns imports.
2026-06-02 15:13:55 +02:00
Luca 2723b3f29e fix(business-profile): honor 24h time format in business-hours pickers
Native <input type="time"> renders AM/PM from the browser locale, ignoring
general_time_format. Pin a lang hint (en-GB for 24h, en-US for 12h) so
Chrome/Edge render the admin's chosen format. Display-only; the stored
value was already 24h HH:MM.
2026-06-02 15:03:35 +02:00
Luca 333379b321 Merge branch 'beta' of https://github.com/the-luap/picpeak into feat/crm-improvements 2026-06-02 14:17:46 +02:00
Luca a5c88f2d9f i18n(crm): fill missing DE/EN keys on CRM admin surfaces
The t() calls on the Features tab, CRM settings tab, contract editor,
and settings nav carried English fallbacks but the keys were absent from
both locale files, so DE rendered English. Add ~67 keys to en.json +
de.json (DE native): the full contracts.editor.* subtree (whole page was
English), settings.features.{contracts,crmDevelopment,hoursLogging}.*,
crmSettings contracts/dashboard-overview/ToS labels, and two settings
nav titles. Also drop a dead duplicate bills.field.sourceQuote key in de.
No code changes — additive translations only.
2026-06-02 14:11:44 +02:00
Luca 603ba1e504 feat(email): read-only "Sent emails" tab over email_queue
Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
2026-06-02 13:58:54 +02:00
Luca 8d14441df4 feat(quotes): admin decline-on-behalf with optional reason
Add a "Decline on behalf" action mirroring accept-on-behalf, for when a
customer says no by phone/email. Flips a draft/sent/expired quote to
declined, stamps declined_at, closes the public response window, and
invalidates outstanding accept/decline tokens so the emailed link can't
toggle it back. Optional free-text reason persisted to a new
quotes.decline_reason column (migration 115) and shown on the quote
detail page. Hard-delete intentionally not included.
2026-06-02 13:47:31 +02:00
Luca 621ce942b5 feat(email): per-weekday business hours + manual queue flush
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
2026-06-02 13:00:13 +02:00
Luca 93956db0ca feat(hours): aggregate open-hours landing view on /admin/clients/hours
When no customer is selected, list every customer with unbilled hour
entries — entry count, total hours, and open amount (resolved via the
override → customer-rate → install-default chain). Rows with no
resolvable rate are flagged "Rate not set" rather than undercounted.
Click a row to drill into the per-customer logging section.

Backend: getUnbilledSummaryByCustomer() + GET
/api/admin/customers/hour-entries/unbilled-summary (customers.view).
2026-06-02 11:43:42 +02:00
Luca ab6bad17c9 feat(hours): install-wide default rate + inline missing-rate CTA
Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
2026-06-02 11:33:45 +02:00
Luca d9251c0850 feat(crm): Finder-style sortable column headers, default sort by issue date
Replace the sort <select> dropdowns on the invoice, quote and contract
list pages with clickable column headers that toggle asc/desc and show a
chevron indicator. Adds a shared SortableHeader component + useColumnSort
hook that maps clickable columns onto the server-side sort enum.
Make issue date (newest first) the standard sort on all three lists,
set at the frontend, route and service layers. Adds issue_asc/issue_desc
to invoices and an "Issued" column to the bills table so the default is
visible and toggleable. Extends sort coverage so every clickable column
has both directions (+customer_desc on all; +issue_asc/desc on
quotes/contracts). Storno rows remain listed.
2026-06-02 10:59:36 +02:00
Luca 095edfe06d fix(bills): localize the event_date on the invoice detail card
The Anlass field rendered inv.eventDate verbatim (raw ISO from pg
date-as-Date serialization) while every other date on the card went
through useLocalizedDate. Route it through fmtDate so it honors the
general_date_format setting. (The event_id → /admin/events linkify was
already in place.)
2026-06-02 10:04:23 +02:00
Luca db7d11dc7f feat(bills): capture event name/date when importing historical invoices
The historical-invoice import form had no event field, so imported rows
landed with event_name = NULL even when the admin knew the occasion. Add
free-text Event name + Event date inputs to the import modal, thread them
through billsService.importHistorical and the POST /admin/invoices/import
validator, and store them in the event_name/event_date snapshot columns
(migration 107). event_id stays NULL — no FK, since the event may predate
picpeak. Autocomplete-to-event_id linking deferred as a future bonus.
2026-06-02 09:50:51 +02:00
Luca c2d3f663bc fix(settings): use CountrySelect for business-profile country (no more FL)
The business-profile country field — the seed source for the customer-create
country default — was still a free-text input placeholdered "FL", which could
reintroduce the non-ISO "FL" code that migration 110 normalized to "LI" and
re-open the create/edit CH-vs-FL default mismatch. Swap it for the shared
CountrySelect so every surface stores ISO alpha-2. The free-text countryName
verbatim-PDF override (migration 107) is unchanged.
2026-06-02 09:37:33 +02:00
Paul Nothaft d57a59a4c1 Merge pull request #601 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.1-beta.0
2026-06-02 09:29:23 +02:00
Luca 06b4522dec feat(billing): manual cadence + fix admin date inputs ignoring date-format setting
Manual billing cadence
  Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
  the monthly draft accumulator — invoices and billed hours pile onto one running
  draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
  never matches. The draft ships only when an admin clicks "Trigger invoice now".
  No migration: billing_cadence is a free-form string column gated by validators.
  - adminCustomers.js: allow 'manual' in billing_cadence validator
  - invoiceService.js: route manual through accumulator; NULL periods + placeholder
    issue/due date in getOrCreateMonthlyDraft
  - customerHoursService.js: manual auto-appends hours to running draft;
    billUnbilledEntries refuses manual (CADENCE_MISMATCH)
  - CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
    NULL-period-safe draft preview + trigger button, manual-specific copy
  - customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
  - en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
    draftPreview.titleManual
Date-format fixes
  Replace raw <input type="date"> (browser-locale) with LocalizedDateInput so
  these admin surfaces honor the general_date_format setting:
  - ContractEditorPage.tsx (issue / valid-until / event dates)
  - QuoteEditorPage.tsx (event / valid-until dates)
  - EventDetailsPage.tsx (expiry date)
  - HoursSection.tsx (entry date)
2026-06-02 09:27:10 +02:00
github-actions[bot] 3361a8bb51 chore(beta): release 3.60.1-beta.0 2026-06-02 07:17:10 +00:00
Paul Nothaft 940fc60740 Merge pull request #598 from the-luap/fix/notifications-clear-all-597
fix(notifications): restore /clear-all route the frontend already calls (#597)
2026-06-02 09:16:47 +02:00
Paul Nothaft 316ad5fa2e Merge pull request #600 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.60.0-beta.0
2026-06-02 09:16:24 +02:00
github-actions[bot] 987911a03b chore(beta): release 3.60.0-beta.0 2026-06-02 07:11:30 +00:00
Paul Nothaft 9d424d0dbb Merge pull request #596 from Luca-Timo/bugfix/crm-backup
Backup & Restore hardening — close the silent files-only data-loss class
2026-06-02 09:10:57 +02:00
Luca d788a6cde5 feat(crm): per-customer Skonto opt-out
Adds customer_accounts.skonto_disabled (migration 112) so a customer
that negotiated "no early-payment discount" can be flagged once instead
of ticking the per-invoice toggle on every invoice. resolveSkontoPercent
ForInvoice and the PDF render context both honour it, extending the
resolution chain to customer → invoice → snapshot → quote → global.
Checkbox added to the customer detail Billing card (en + de).
2026-06-02 09:00:05 +02:00
Luca 45c7cc80ab fix(invoices): anchor issue date + Skonto window to the actual send date
A scheduled invoice's issue_date was stamped at creation, so a long-
scheduled invoice printed a stale date by the time it shipped — the
relative Skonto window ("pay within N working days") and the net-days
due date were then counted from the authoring day, not the send day.
sendInvoice now stamps issue_date = send date on the first send and
re-derives the due date from it, preserving a manual due-date override.
Adds resolveNetDaysForRow to read net days from the persisted snapshot.
Deselecting Skonto before the scheduled send already propagates (the
scheduler re-reads the row fresh and the render context honours
skonto_disabled); no change needed there.
2026-06-02 08:54:23 +02:00
Luca 7c39a30957 feat(bills): link event label to its event detail page
The Anlass / event name on the invoice detail page and the bills list
now links through to /admin/events/:id when the invoice references a
real event row. The list link stops propagation so it doesn't trigger
the row's invoice navigation. Falls back to plain text when the invoice
carries only a free-text event snapshot. Customer portal unchanged
(no admin route access).
2026-06-02 08:38:07 +02:00
Luca 522cf2aa4a fix(invoices): auto-track due date from send date + payment term
Due date now derives from (scheduled send date else issue date) plus the
selected Net-days template, both in the editor and on save. The bill
editor renders it read-only with an Override toggle for manual entry;
existing invoices preserve their stored due date. Backend adds a single
resolveNetDays resolver that honors the split payment-net-days template
(previously only the legacy FK was read) and the
crm_payment_default_net_days setting, used by createInvoice and the
installment-spawn path alike.
2026-06-02 01:52:45 +02:00
Luca c6b8cc9199 fix(crm): anchor imported invoice dates to issue_date, not import time
The invoice-import endpoint stamped sent_at and paid_at with the moment
of import (new Date()) instead of the document's historical dates. The
CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a
year-old paid invoice imported today wrongly counted toward the rolling
window. The dashboard windowing is correct (cash-basis "received in the
window") — the bug was the wrong paid_at on imported rows.

POST /admin/invoices/import now anchors sent_at to issue_date and
paid_at to issue_date (or an optional new paidAt param when the admin
knows the real payment date), never to import time.

Migration 111 backfills rows imported under the old behaviour: for every
invoice with imported_pdf_path set, sent_at/paid_at are reset to
issue_date. The old code never captured a real payment date, so
issue_date is the only sensible anchor. Idempotent and scoped strictly
to imported rows, so picpeak-issued invoices are untouched.

paid_at/sent_at are operational timestamps, not the invoice's immutable
legal content, so correcting the import-time error is safe under the
§14/§11 UStG immutability rule.
2026-06-02 01:36:52 +02:00
Luca db2c482ae9 feat(crm): country dropdown + name guard for customer create/edit
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.

Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).

Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.

i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
2026-06-02 01:28:00 +02:00
Luca 840df52581 fix(crm): respect general_date_format on all admin date inputs
Admin date inputs were inconsistent: raw <input type="date"> on event
creation and the bill editor rendered in the browser locale (en-US users
saw MM/DD/YYYY regardless of Settings -> General), while the historical-
invoice import modal used a private LocalizedDateField that displayed the
configured format but showed a text box plus a tiny native date stub
side-by-side ("two date fields, looks corrupted").

Extract a single shared LocalizedDateInput that displays/parses in the
configured general_date_format on every browser and opens the native
picker via a calendar icon button (showPicker on a visually-hidden native
input), so there is one date field, not two. Wire it into event creation,
the bill editor (event/issue/due dates), the import modal, and the tax-
report range filters (dropping the Chromium-only lang={dateInputLang}
workaround there).
2026-06-02 01:15:40 +02:00
Luca 7988c18972 fix(restore): set was_successful=true on the completed update
Caught during the round-4 e2e validation on real PG: every
successful restore landed with `status='completed', was_successful=false`
because the success-branch update only wrote `status` but not
`was_successful` (column default is false). Visible side effect: the
BackupDashboard's "last successful restore" filter would skip the
row + any future audit query gating on was_successful would miss it.

One-line cure: include `was_successful: true` in the success-branch
update payload. Inline comment explains why and references the
review note so future edits keep the two fields together.

Source-inspection test in restoreService.pgBranch.test.js pins the
contract: after `performPostRestoreVerification(...)`, the
`status: 'completed'` update payload must also contain
`was_successful: true`. Future refactors of the success payload that
drop the flag fail the test before merge.

36/36 backup-related integration tests pass.
2026-06-01 22:56:02 +02:00
Luca 20e3092c14 fix(restore): move operator-meta replay after post-restore verification (PR #596 round 3)
End-to-end DR cycle surfaced one more PG-only landmine — and it
turned out to be a side-effect of the round-1 replay placement, not
a new bug. Round 2 fixed the comparison logic; round 3 fixes the
ordering.

Symptom on real PG install:

  [install-from-backup] FAILED — Post-restore verification failed:
    Table app_settings row count mismatch: expected 190, got 191.
    Trigger file left in place for retry.

Root cause: the operator-meta replay (introduced in round 1) ran
INSIDE performDatabaseRestore, lined up BEFORE the post-restore
verification step in the parent restore() method. So:

  1. psql restores app_settings → 190 rows (matches backup)
  2. Replay upserts `restore_allow_force_auto_upgraded` (which the
     fresh-install seeded but the backup didn't have) → 191 rows
  3. performPostRestoreVerification counts 191, manifest says 190,
     verification fails the row-count check.

Replay is doing the right thing (preserving operator policy). The
verification is doing the right thing (counts must match). They
disagree because the replay landed in the wrong sequence relative
to verification.

Cure: move the replay out of performDatabaseRestore and into
restore() AFTER `performPostRestoreVerification` passes.
Verification now sees the as-restored DB (matches the backup
exactly), replay layers on top once verification has signed off.

Mechanism: snapshot stashed on `this.preservedMetaSnapshot`
(initialised in constructor, reset per run at the top of restore()).
performDatabaseRestore writes it in the PG branch before DROP;
restore() drains it after verification. SQLite leaves it empty,
both steps no-op there.

Tests:
  - Updated `restoreService.pgBranch.test.js` to pin the new shape:
    * `this.preservedMetaSnapshot` is initialised in the constructor
    * No stray `let preservedMeta = []` local declarations anywhere
    * Replay drain (`this.preservedMetaSnapshot.length > 0`) sits in
      restore() AFTER `performPostRestoreVerification(...)` and is
      lexically OUTSIDE `performDatabaseRestore`.
  - The bigint-as-string contract from round 2 still holds.

34/34 backup-related integration tests pass.
2026-06-01 22:44:51 +02:00
Luca 354fbed182 fix(restore): coerce pg bigint counts to Number before comparing (PR #596 round 2)
pg-driver serialises `bigint` (which is what `COUNT(*)` returns) as a
JavaScript STRING to preserve precision for huge counts. The manifest
stores `expected.rowCount` as a JS number (parseInt'd at
databaseBackup.js:118). Strict `!==` in performPostRestoreVerification
flagged every match as a mismatch on PG:

  Table activity_logs row count mismatch: expected 16, got 16
  Table admin_users row count mismatch: expected 1, got 1
  Table app_settings row count mismatch: expected 165, got 165
  ... (every table, all matching)

Symptom matched the preservedMeta scope leak from round 1: install-
from-backup logged FAILED, trigger file wasn't cleaned, data was
actually intact. Caught on PR #596 e2e re-run.

Cure: coerce both sides with `Number(...)` at the comparison AND in
the interpolated value so the warning text renders `16` not `"16"`.

Pre-emptive: lines 448 + 458-459 had the same string-vs-number issue
masked by `>` (JS coerces operands for `>`), but the warning text
printed `"5"` on PG vs `5` on SQLite, and a future patch changing
`>` to `=== 0` or `!== expectedCount` would silently break on PG.
Coerced at the read site into `eventCountN` / `activeUsersN` locals
+ added a comment block explaining the contract so future edits
don't drop the Number() calls without re-auditing.

New source-inspection test: pins the contract that every `.count`
result in restoreService.js MUST be wrapped in `Number(...)` when
used in a comparison (===/!==/>/</>=/<=). Same source-inspection
pattern as the preservedMeta test added round 1 — pragmatic until
the real-PG integration test follow-up lands.

The maintainer's audit of the rest of the backup/restore surface
(_installFromBackupBoot, _restoreSettingsBoot, _backupPathsBoot,
backupCoverageService, backupIntegrityService, backupService,
databaseBackup) confirmed no other bigint-as-string sites — the
class is now closed in the audited scope.
2026-06-01 22:23:26 +02:00
Luca 3322a1d998 feat(restore): docker-logs visibility + ADMIN_CREDENTIALS.txt restore notice
Two nice-to-haves from the PR #596 review.

1. Install-from-backup logging mirrors to stdout
   The winston logger writes to /app/logs/combined.log and may not
   tee to stdout. Operators tailing `docker logs picpeak-beta-backend`
   after a `compose up` saw the migration sweep + npm notice and
   nothing about the restore. Three key events now also fire through
   `console.log` with a `[install-from-backup] ` prefix:
     - "trigger file detected → <manifest>"
     - "starting restore from <manifest>"
     - "restore completed successfully" / "FAILED — <reason>"
   Plus the "skipping — existing data" branch.
   docker-logs surface now tells the restore story without requiring
   an `exec into the container` step.

2. ADMIN_CREDENTIALS.txt flags stale creds when restore is queued
   Migration 001 detects a pending `RESTORE_ON_INSTALL` file BEFORE
   writing the fresh-install credentials file. If a trigger will fire
   on the next boot, the file now opens with a clear warning:

     ⚠️  RESTORE_ON_INSTALL TRIGGER DETECTED ⚠️
     These credentials are temporary. An install-from-backup run is
     queued to fire on the next server start, which will REPLACE
     this admin row with the one from the backup. After the restore
     completes, log in with your ORIGINAL pre-disaster credentials
     — not the ones below. If the restore fails for some reason,
     the credentials below remain valid as a fallback recovery path.

   Doesn't skip the file (so a failed restore still has the fallback
   credentials), just annotates it. Closes the maintainer's "stale
   junk credentials" observation.
2026-06-01 21:52:38 +02:00
Luca a23fa3bb12 fix(restore): hoist preservedMeta above SQLite/PG split (PR #596 blocker)
`preservedMeta` was declared with `let` INSIDE the PostgreSQL else
branch of performDatabaseRestore (~L850), then read AFTER the else
block closed at the shared replay site (~L1030). On every real PG
restore, this threw:

  ReferenceError: preservedMeta is not defined

after psql had already loaded the data successfully. Knock-on
effects per the maintainer's review:

  - Loud `Install-from-backup: FAILED` line in combined.log even
    though the data restored cleanly
  - Trigger file in `_installFromBackupBoot.js` was left in place
    because the success branch never ran — admin had to manually
    rm it before the next boot
  - The operator-meta replay (restore_allow_force,
    restore_allow_force_auto_upgraded) silently dropped, exactly
    the chicken-and-egg the snapshot was added to close.
    `restore_allow_force` reverted to the backup's value on every
    PG restore.

CI missed it because integration tests around `performFullRestore`
only exercise the SQLite branch (`this.dbType === 'sqlite'`). The PG
branch requires a real psql binary + cluster, which lives in the
"real-PG integration test in CI" follow-up.

Cure: hoist the `const PRESERVED_META_KEYS = [...]` + `let
preservedMeta = []` declarations above the SQLite/PG split. SQLite
leaves them empty; PG branch fills them; replay block at the bottom
reads them on both paths (no-op on SQLite).

New test: `restoreService.pgBranch.test.js` pins the scope contract
via source inspection. Two assertions:
  1. Exactly one `let preservedMeta = []` declaration in the file,
     positioned before the SQLite/PG branch split
  2. The replay block `if (preservedMeta.length > 0)` sits outside
     the else block (closing `      }` exists between the branch
     opener and the replay site)
Source-inspection beats a runtime test here because (a) it doesn't
need a real PG cluster + psql binary, (b) it pins the EXACT property
that broke, more directly than a runtime test would.

Closes PR #596 review blocker.
2026-06-01 21:51:49 +02:00
Paul Nothaft 29e63e5ce5 fix(notifications): restore /clear-all route the frontend already calls (#597)
The AdminHeader "Clear All" notifications button has been 404'ing for
a while: frontend `notifications.service.ts` calls
`DELETE /admin/notifications/clear-all`, backend only defined
`DELETE /admin/notifications/clear-old`.

The /clear-old route was misleadingly named anyway — it tried to
delete read OR >30-days-old rows, then had a fallback that nuked
EVERY row when nothing matched. Both the frontend and the existing
test expect a simple Clear All shape, so just rename to /clear-all,
drop the tiered logic, and return the plain
`{ message, deletedCount }` payload the test asserts on.

The test (adminNotifications.test.js) was hiding the breakage —
it was on CI's --testPathIgnorePatterns ignore list and so never
ran. Two reasons it failed locally before this fix:
  1. Route path mismatch (the actual #597 bug).
  2. The mock only stubbed adminAuth — requirePermission lives in
     its own middleware module and ran for real, 403'ing before
     the handler. Add a passthrough mock for that too.

With both fixed, the test passes. Drop adminNotifications from the
CI ignore list so future regressions in this route fail loudly
instead of going to ground.
2026-06-01 19:31:44 +02:00
Luca 205802fb9d Delete .claude/security-reports/2026-05-22-idor-crm-admin-endpoints.md 2026-06-01 10:45:38 +02:00
Luca 1a65d9d2f9 Delete .claude/drafts/issue-48-reply.md 2026-06-01 10:44:57 +02:00
Luca 07f9110674 chore(migrations): renumber 108_add_backup_paths to 109 to avoid upstream collision
upstream/beta independently shipped 108_seed_sl_email_template_translations.js
(Slovenian email template translations) using the migration number
this branch had already claimed for 108_add_backup_paths.js. Knex's
filename-based ordering would have caused both to attempt the slot
at merge time.

Renamed via `git mv` so file history is preserved. All five
references updated in lockstep:
  - backend/src/services/_backupPathsBoot.js (require + comments)
  - backend/src/services/backupService.js (LEGACY_BACKUP_PATHS comment)
  - 3 integration test files (require + "migration 108" prose)
  - migration's own header comment, with a paragraph explaining the
    rename so reviewers don't wonder why the number jumped

**No data-migration impact for installs that already ran the
108-named version** (Ralf's beta, primarily): the migration's body
is idempotent — createTable is guarded by `hasTable`, and the seed
uses `onConflict('path').ignore()`. So when 109 runs against an
install whose backup_paths table is already populated, both the
schema step and the seed step no-op cleanly. The orphaned
`108_add_backup_paths.js` row in the `migrations` tracking table
sits harmlessly alongside the new `109_add_backup_paths.js` row.

No data lost, no double-insert, no schema drift. Mechanical rename
ahead of the PR opening.
2026-06-01 00:31:08 +02:00
Luca 43cb0ea4bf docs: consolidate disaster-recovery into Backup & Restore guide
The previous split (separate docs/install-from-backup.md + separate
README link for "Disaster Recovery") fragmented what's conceptually
one workflow: backup → restore. DR is a specific scenario of restore
(the destination is wiped), not a separate feature.

This merge:
  - Folds install-from-backup content into docs/backup-restore.md
    as a "Disaster recovery (install from a backup)" section with
    its own table-of-contents anchor.
  - Adds an explicit ToC at the top so admins land on what they
    need in one click.
  - Frames the two restore paths up front: "live install" → wizard,
    "fresh / wiped install" → trigger file. Admins encountering DR
    in panic mode don't need to know to look under a separate link.
  - Drops the duplicate "Disaster Recovery" README bullet. The
    "Backup & Restore" blurb now mentions DR explicitly so it's
    still findable via Ctrl+F on the README.
  - Removes docs/install-from-backup.md (its content is now in
    backup-restore.md's DR section).

Single source of truth = less risk of one doc going stale relative
to the other when the feature evolves. Maintainer-facing surface
on docs.picpeak.app shrinks back to one /guides/backup-restore page.
2026-06-01 00:21:39 +02:00
Luca 09f6a1af6a fix(backup-ui): respect general_date_format + general_time_format
The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.

Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.

All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).

Tokens replaced:
  format(date, 'p')              → formatTime(date)
  format(date, 'PP')             → format(date)
  format(date, 'PPP')            → format(date)
  format(date, 'PPp')            → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm')    → formatDateTime(date)

No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
2026-05-31 23:33:50 +02:00
Paul Nothaft 3b2c74803b Merge pull request #595 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.59.1-beta.0
2026-05-31 23:12:23 +02:00
github-actions[bot] b646dab493 chore(beta): release 3.59.1-beta.0 2026-05-31 21:10:15 +00:00
Paul Nothaft c68a03c20e Merge pull request #594 from the-luap/fix/bugs-batch-523-564-590-591-592
fix(bug-batch): #523 #564 #590 #591 #592
2026-05-31 23:09:49 +02:00
Paul Nothaft c246fd3cc8 fix(admin-header): hide wordmark on <sm when logo also shows (#523)
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.

Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.

Truncate stays in place as defensive depth for the text_only path.
2026-05-31 23:07:19 +02:00
Luca 83fdb47fbf feat(installer): install picpeak directly from a backup via trigger file
Closes the six-step DR dance ("onboard throwaway admin → restore via
wizard → log out → log back in with originals") by letting admins
recover an install with zero clicks past `docker compose up`.

Convention: drop a file named `RESTORE_ON_INSTALL` (no extension OR
.txt) into the existing `/backup` bind mount. On next container
start, the new boot hook detects it, runs the restore, and starts
the server with the restored state. Admin opens the browser, login
works first try.

Payload variants:
  - empty file → auto-picks newest backup-manifest-*.json from
                 /backup/manifests/. Useful for "restore the latest".
  - path inside the file → uses that specific manifest. Useful for
                 "I want this older backup, not the most recent".

Safety gates (three layers):
  1. Trigger file must exist — no auto-magic, admin signals intent
  2. DB must be empty (no events, ≤1 admin) — refuses to clobber
     production data
  3. Restore failure leaves the trigger file in place for retry on
     next container start. Success deletes it so subsequent boots
     don't redo the work.

Override hook: INSTALL_FROM_BACKUP_FORCE=true skips guard #2 for the
"I know what I'm doing" edge case (dev env rebuilds, etc).

No docker-compose changes required — uses the bind mount picpeak
already has, env vars are optional. The minimal admin workflow now
matches the bare-minimum mental model: "copy my backup files,
restart the container, log in with original credentials."

Tests: 7 scenarios covering trigger detection, payload variants,
safety gates, success/failure trigger-file lifecycle.
2026-05-31 23:06:41 +02:00
Paul Nothaft 8c6525af01 test(v1/events): update mock chains to cover new app_settings probes
The #592 fix added a devtools-detection probe, and the #592 follow-up
added a require_password probe + a branding-defaults whereIn().select().
Both shift the db() call indices the existing #550 test relied on, and
the branding probe needed `.select()` to resolve to an array (the mock
chain wasn't thenable, so `for..of` on the result threw → 500 on every
test that hit BASE_BODY).

Add `whereIn` + `selectResult` to buildChain so the branding probe
yields an iterable. Factor the three pre-slug app_settings chains into
a baseSettingsChains() helper and update each test's queued sequence
and toHaveBeenNthCalledWith / toHaveBeenCalledTimes expectations to
match the new shape. No behaviour change in v1/events.js — only the
test scaffolding moves.
2026-05-31 23:02:51 +02:00
Luca e7dffa656b feat(backup-stats): per-Stage-B-path counters in backup statistics
Closes the last gap from tonight's backup-hardening: backup_runs.
statistics now carries a `per_path` map keyed by backup_paths.path
(e.g. `events/active`, `business-docs`), with per-bucket count + size.

Backend (backupService.js):
  - new `computePerPathStats(backedUpFiles, allFiles)` helper that
    bucket-sorts each backed-up file into its owning backup_paths row
    by longest-prefix match. Reuses the same backup_paths source the
    walker reads, so toggling include_in_default off propagates
    correctly. Falls back to LEGACY_BACKUP_PATHS if the table is
    missing.
  - runBackupInternal calls it after the destination implementation
    reports back, includes the result in statistics under both
    snake_case (`per_path`) and camelCase (`perPath`) keys for the
    same alias treatment the existing fields get.

Frontend (BackupHistory.jsx):
  - Backup History detail pane now renders one row per per_path entry
    when present, with path label + count + formatted size.
  - Falls back to the legacy Photos / Archives / "Other" rendering
    when the field is absent (backups taken before this commit). No
    breaking change for stored history.

Tests: new backupService.perPathStats.test.js — 2 scenarios pinning
attribution behaviour (single-path, nested-paths-don't-collide).
Plus a NOTE comment about overlapping-path walker behaviour (out of
scope; canonical seed doesn't hit it).
2026-05-31 22:54:09 +02:00
Luca e0ace0864e fix(restore): preserve operator-meta settings across restore
Closes the chicken-and-egg where `restore_allow_force` (and its
auto-upgrade tracking flag) got overwritten on every restore by
whatever value happened to be in the backup. Net effect:

  1. Admin enables Force Restore (via tonight's default-ON migration
     edit, or hand-SQL on older installs).
  2. Restore runs successfully.
  3. Restored DB has `restore_allow_force = <backup's old value>`.
  4. Next restore attempt: "Force restore is not allowed by system
     settings" — admin needs the SQL workaround AGAIN.

Cure: snapshot a small list of operator-meta keys BEFORE the DROP
DATABASE (while we still have a working pool against the OLD DB),
then UPSERT them back AFTER the psql restore + migrate.latest.

The preserved set is intentionally narrow — currently just
`restore_allow_force` and `restore_allow_force_auto_upgraded`. These
are about how the operator wants the install to behave, not user-
facing state. Adding more keys is a one-line addition to the
PRESERVED_META_KEYS constant.

Survives both:
  - backup is OLDER than the operator's most recent setting change
  - backup is NEWER but had a different operator policy
Either way, the post-restore install reflects the LIVE operator
policy, not the backup's snapshot of it.
2026-05-31 22:48:56 +02:00
Paul Nothaft 791e9974eb fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up)
The in-session toggle fix in d292b9f handles click 2 correctly, but
on a hard refresh likedPhotoIds was always initialized to an empty
Set — so previously-liked photos rendered un-filled until the user
opened the lightbox.

Backend: gallery.js GET /:slug/photos now mounts resolveGuest and
emits a per-viewer is_liked boolean per photo. Prefers req.guest.id
when a verified guest token is present (per-person identity), falls
back to the IP+UA hash that generateGuestIdentifier produces — same
identity model galleryFeedback.js uses for /my-feedback. Skipped
when feedback is hidden from guests.

Frontend: Photo type gains optional is_liked. Each of the 7 grid
layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic /
Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked)
on the first non-empty payload, gated by a seededRef so subsequent
React Query refetches don't clobber in-session optimistic toggles.
Mosaic uses photo.is_liked ?? false in its per-card useState initializer.

GalleryPremium also drops the buggy `|| like_count > 0` fallback at
line 521 that treated "anyone liked this" as "I liked it" — the
per-viewer seed is now the correct source.

GalleryStory had the same shape of bug in two places — same #590 fix:
- Seed switched from like_count > 0 (global) to is_liked (per-viewer),
  with the same mount-only seededRef guard.
- handleToggleFavorite now calls submitFeedback on EVERY click, not
  only when adding. The previous code skipped the unlike submit, so
  the UI removed the heart while the server kept the like row.
2026-05-31 22:47:16 +02:00
Paul Nothaft 2d44b1ab2d fix(api/v1/events): also honour require_password + branding defaults (#592 follow-up)
Same class of bug as the devtools-detection gap landed in 2304b25.
v1 POST /events was hardcoding require_password=true in the destructure
default and skipping getBrandingDefaults entirely, so:

- Admins who disabled "require password by default" globally still
  got password-required galleries through the API.
- API-created events ignored the global branding_logo_display_hero
  and branding_logo_size toggles, defaulting to visible/medium
  regardless of the admin's preferred branding chrome.

Mirror the readBooleanSetting + getBrandingDefaults pattern from
adminEvents.js inline (helpers aren't exported, and pulling them out
is out-of-scope for this fix). Adds validators, fallback resolution,
and the three resolved values to the events insert. hero_logo_position
stays at 'top' since #357 / migration 084 explicitly disconnected it
from the header-bar branding_logo_position setting. OpenAPI updated.
2026-05-31 22:47:01 +02:00
Luca 989b42c2f1 fix(restore-wizard): warn on backups without a database dump
Closes the loop on the original 2026-05-29 data-loss class: Ralf had
four "Run Backup Now" manifests sitting on disk with
database.backup_file = null because Stage A wasn't yet in place.
The restore wizard would have happily restored any of those four,
bringing back files (photos, PDFs) but leaving the database empty —
silently re-creating the exact data loss the rest of this branch
prevents going forward.

The /restore/list-backups endpoint now returns `database_included`
per row (parsed from the manifest at discovery time). The wizard
uses it to:

  - Per-row badge: red "No DB" pill next to any backup where
    database_included === false. Tooltip explains the consequence
    in plain English: "restoring this will NOT recover the database".
  - Selected-card callout: full red banner under the chosen row
    when database_included is false, restating the warning + giving
    the admin a clear path: "pick a different backup if you have
    one with a database dump, or proceed only if files-only is
    what you want."

The wizard does NOT block the restore — the admin may genuinely want
a files-only restore (e.g. recovering a deleted photo while keeping
current DB state). The warnings make sure that choice is informed.
2026-05-31 22:44:37 +02:00
Luca 155aa63103 fix(backup-dashboard): show last SUCCESSFUL backup + last attempt separately
The dashboard widget used `lastBackup.created_at` for the "Last
successful backup: X ago" text — but lastBackup is the most recent
row of any status. So a crashed restore (status=running, never
updated) or a recent failure showed up labeled as the last
successful backup. Same "silent failure not surfaced" class the
restore wizard had.

Backend now returns:
  lastSuccessfulBackup — most recent backup_runs with status='completed'
  zombieRuns — running rows older than 30 min (likely crashed mid-flight)
  lastBackup — unchanged (most recent any status)

Frontend renders:
  - "Last successful backup: X ago"  — always from lastSuccessfulBackup
  - "Last attempt: Y ago · failed/running"  — when lastBackup differs
    from lastSuccessful. failed shows the first line of error_message
    in red; running stays neutral.
  - Zombie callout — "N backup(s) running >30min — may have crashed"
    in amber, so admin sees stuck rows at a glance.
  - Health score downgrades from "excellent" to "warning" if the
    latest attempt failed, even when older successes keep the age
    fresh — surfaces regressions without erasing the green history.
2026-05-31 22:44:22 +02:00
Luca 47ed6907d1 fix(restore-wizard): surface failure status, stop showing "completed at 0%"
The progress step used a binary `isRunning ? "in progress" : "completed"`
check. So when the backend rejected the restore (pre-flight validator
threw, path error, etc.) the wizard cheerfully rendered "Restore
completed" with 0% progress and no error context — the admin had to
SSH into the server and inspect `restore_runs.error_message` to find
out what happened.

Now reads the most recent row from `restoreStatus.history[0]` and
renders one of three states:
  - running    → blue text, progress bar updates
  - succeeded  → green tick + post-restore actions (existing behaviour)
  - failed     → red banner with the first line of error_message, and
                 a callout if was_rollback_attempted is true so the
                 admin knows the destination is safe to retry on top of.

Net: the wizard now tells the truth about what just happened.
2026-05-31 22:44:00 +02:00
Luca 48e9c9c79a fix(restore): re-init knex pool after DROP/CREATE DATABASE
`db.destroy()` during restore tore down the in-process connection
pool to release PG sessions so DROP DATABASE could succeed. After
CREATE DATABASE + psql restore, the old code did
`require('../database/db')` expecting a fresh instance — but Node
caches require results, so it got the SAME destroyed instance back.
Every subsequent query in the process failed with "Unable to acquire
a connection" until the container was manually restarted, even
though the restore technically succeeded.

Net effect for admins: login showed "An error occurred", customer /
invoice / quote pages were blank, no surface hinted at the dead pool.

Cure: db.js now wraps the live knex instance in a Proxy that forwards
to a mutable internal reference, with a `reinitPool()` function that
destroys the old instance + builds a fresh one + probes with `SELECT 1`
so any reconnect failure surfaces immediately. The thousands of
existing `const { db } = require(...)` imports work unchanged — they
capture the Proxy once, and every call goes through to the current pool.

restoreService calls reinitPool() after CREATE DATABASE and before
migrate.latest(), so the rest of the request + every subsequent admin
action runs against the fresh pool. Container restart no longer
needed after restore.
2026-05-31 22:43:40 +02:00
Paul Nothaft 2304b25624 fix(api/v1/events): honour global devtools-detection default on create (#592)
Same class of bug as #550 part 2 (feedback default ignored on API
events): the events table column default for enable_devtools_protection
is true, so an admin who disabled detection globally still got it ON
for every API-created gallery.

Mirror the feedback fallback that landed in 1b521e7 — accept an
optional enable_devtools_protection body field, fall back to the
app_settings entry of the same name, and write the resolved value
explicitly on insert so the column default doesn't shadow it.
OpenAPI doc updated to match.
2026-05-31 22:35:23 +02:00
Paul Nothaft c83e88348f fix(nginx): defensive large_client_header_buffers bump (#591)
Default nginx is 4 8k — too tight when an outer Cloudflare /
corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when
a power-user accumulates many per-gallery gallery_token_<slug>
cookies over the 24h maxAge in tokenUtils.js. Either way users hit
"400 Request Header Or Cookie Too Large" and clearing cookies is
the only workaround.

4×32k is cheap RAM, matches what most reverse proxies do upstream,
and means PicPeak doesn't fail the request before the upstream even
sees it.
2026-05-31 22:35:19 +02:00
Paul Nothaft d292b9fa10 fix(gallery): toggle (not add) the local liked set on click (#590)
The /feedback like endpoint is a server-side toggle — the same one
the lightbox uses. Every grid layout's optimistic-UI setter only
ever did next.add(photoId), so click 2 on a liked tile fired a
server unlike but kept the heart filled in the UI.

Switch each setter to toggle (delete if present, else add). Covers
Masonry (default), Grid, Justified, Timeline, Carousel, Mosaic, and
Premium layouts — including their identity-modal callback paths for
shape consistency. Lightbox toggle is unchanged (already correct).
2026-05-31 22:35:15 +02:00
Paul Nothaft e7cf834325 fix(admin-header): truncate long company names on narrow widths (#523 regression)
#527 hid the language *name* on <sm to free space for the title.
Since then the right cluster gained dark-mode toggle, notifications,
and the user avatar, and the brand block still had no truncation —
so a long branding_company_name would still push past the available
width into the action buttons on phones.

Defensive fix: min-w-0 on the brand-block wrapper, truncate on the
company-name span, flex-shrink-0 on the logo image. Long names now
ellipsis within the left cluster regardless of how many widgets
fill the right.
2026-05-31 22:35:07 +02:00
Paul Nothaft dcc629cad2 fix(csp): external bootstrap script to survive strict reverse-proxy CSP (#564)
demo.picpeak.app sits behind Caddy + Cloudflare; Caddy replaces the
nginx CSP entirely with one that omits 'unsafe-inline' / hash / nonce,
so the #358 inline theme-bootstrap was being blocked there — admin
loaded a black page, the SPA bundle 404'd, link buttons did nothing.

Move the bootstrap to /public/bootstrap.js served as 'self' so the
script runs under every reasonable CSP without further coordination.
Vite copies /public/* to the dist root at build time (same pipeline
as /favicon-32x32.png), and it remains in <head> without defer/async
so it still runs before <body> paints. The OS-preference @media CSS
above still handles the first-frame dark/light baseline.
2026-05-31 22:35:00 +02:00
Luca c435263744 fix(restore): default restore_allow_force=true + auto-upgrade existing installs
Root cause of the persistent "Force restore is not allowed by system
settings" error even on fresh installs after `docker compose down -v`:

  migrations/core/032_add_restore_runs_table.js seeded the row with
  `JSON.stringify(false)` = the literal string 'false'.

So every install (fresh OR upgraded) wrote restore_allow_force=false
at migration time. The boot self-heal added earlier today saw the row
and respected "admin policy" per its safety design — never noticing
that the row was the deprecated migration default, not an explicit
admin choice.

Cure follows [[feedback_migration_no_compensation]] +
[[feedback_self_heal_pattern]]:

  1. Edit migration 032 IN PLACE — flip seed value from false to
     true. Fresh installs forward get the correct default at install
     time, no boot helper needed.

  2. One-time auto-upgrade in _restoreSettingsBoot.js for installs
     that already ran the OLD migration. Bumps restore_allow_force
     to 'true' iff the current value is the deprecated literal
     'false' AND the new tracking key
     `restore_allow_force_auto_upgraded` doesn't yet exist. The
     tracking flag is always written after the first boot pass, so
     subsequent admin choices (e.g. deliberately disabling force)
     are preserved on every boot after.

  3. Defensive: adminRestore.js getRestoreSettings() now normalizes
     'true'/'false'/'"true"'/'"false"' string shapes to JS booleans,
     not just '1'/'0'. Belt-and-suspenders so any future seeder that
     uses a different boolean serialization doesn't silently break
     the !settings.restore_allow_force gate.

Net effect: any picpeak install pulling this image — fresh or
existing — gets restore_allow_force=true on first boot after the
upgrade. The catch-22 that forced every disaster-recovery admin to
hand-write SQL before their FIRST restore is closed.
2026-05-31 21:35:48 +02:00
Luca dbcecfe2aa feat(restore): self-heal restore_allow_force default ON at boot
Fresh installs of picpeak had `restore_allow_force` defaulting to
false (or missing entirely). Combined with the "1 active admin
user" pre-restore warning that the fresh-install admin auto-creates,
this meant the very first restore on every new install hit:

  Force restore is not allowed by system settings

Admins then had to hand-craft SQL to flip the setting before they
could recover their data — at the worst possible moment, when they
were already mid-disaster.

This isn't security: the admin who can SQL the setting on can also
flip it via the UI. It's just a sharp edge that bites every new
install once.

Cure: boot-time self-heal that seeds restore_allow_force=true only
when the row doesn't exist. Existing installs that explicitly set
the row (true OR false) are NOT touched — admin policy wins.
Pattern mirrors _backupPathsBoot.js and _emailTemplateBoot.js.

Default-ON rationale matches Stage A's principle: the cost of
forgetting (= can't recover from a disaster) outweighs the friction
saved (= adversarial admins can't run forced restores). Audit
logging keeps the accountability story intact.
2026-05-30 21:48:05 +02:00
Luca 7f7c8eee61 fix(backup-history): show Total + Other so per-row sums match the count
The "Content Backed Up" panel in Backup History only counted two
categories (Photos + Archives), so a 3-file backup that landed all
3 in business-docs (Ralf's case after the storage truncation +
restore tonight) showed:
  Photos (0 of 0)
  Archives (0)
  → total: 3 files
The discrepancy made admins wonder where the 3 files actually went.

Adds two rows:
  - "Business documents & other" = files_processed - photos - archives
  - "Total files" = files_processed
So the math adds up regardless of which Stage B path the files came
from. Properly per-path-category breakdown requires backend-side
per-path counters (separate follow-up); this commit closes the
visible-discrepancy gap without that schema change.

i18n: en + de added; other locales fall back to en until reviewed.
2026-05-30 21:22:27 +02:00
Luca cfaa7eb095 fix(restore): re-sync PostgreSQL sequences after psql load
pg_dump emits setval() statements for SERIAL/IDENTITY columns, but
they don't always land cleanly: --clean ordering, knex pool sequence
caching, rows inserted mid-restore (the pre-restore safety backup
writes a database_backup_runs row before DROP), etc. Net result on
Ralf's install after a successful restore:

  - "A record with this value already exists" on every CRUD action
  - duplicate key value violates unique constraint
    "database_backup_runs_pkey" on the next Run Backup Now

Same root cause: every SERIAL column's sequence was pointing at or
below MAX(id), so the next INSERT collided.

Fix: append a DO block after the psql restore that walks pg_class +
pg_attribute and setval()s every public-schema sequence to
GREATEST(MAX(<col>), 1). Cheap (a few ms even on large schemas),
safe (read-only on row data), idempotent — re-running it just
re-asserts the same values.

Seventh latent PG-restore bug discovered on Ralf's install tonight.
Manual hand-fix worked; this commit makes the fix automatic for
every future restore.
2026-05-30 21:10:10 +02:00
Luca a39def672e fix(restore): evict active sessions before dropping target DB
PostgreSQL refuses DROP DATABASE while any session is connected:
  ERROR: database "picpeak_prod" is being accessed by other users
  DETAIL: There are 6 other sessions using the database.

The backend's own knex pool holds 5-25 active connections to the
target DB. So even after closing the request that initiated the
restore, the pool keeps the DB busy and the DROP statement fails.

Three-layered cure, all in the restore service's PG branch:

  1. Call `db.destroy()` first to close the in-process knex pool so
     we don't fight ourselves. Knex will lazily re-open on the next
     query via db.js's retry logic, so this is safe to do mid-restore.

  2. SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE
     datname=<target> AND pid<>pg_backend_pid() — evicts any sessions
     from other processes (other server replicas, leftover idle
     transactions, things our own pool destroy missed).

  3. DROP DATABASE IF EXISTS "<target>" WITH (FORCE) — PG13+ kills
     remaining connections atomically with the DROP. Falls back to
     plain DROP on older Postgres where WITH (FORCE) is a syntax error.

Surfaced as the FIFTH latent bug in the restore path tonight: the
DROP DATABASE statement always assumed a quiescent destination, but
the live backend keeps the destination busy at all times. Every
previous PG install of picpeak that ever tried Restore would have
hit this — meaning the disaster-recovery feature has shipped broken
for a long time without anyone exercising it end-to-end.
2026-05-30 13:20:20 +02:00
Luca 4c31a22626 fix(restore): DROP/CREATE DATABASE needs explicit -d maintenance DB
`psql` with no -d connects to a database whose name matches the
connecting user. On installs where the user's home DB doesn't exist
(common pattern: DB_USER=picpeak, DB_NAME=picpeak_prod, no `picpeak`
DB), the restore's DROP DATABASE / CREATE DATABASE statements failed
with:

  FATAL: database "picpeak" does not exist

even though the target DB (picpeak_prod) was alive and connectable.
And of course you can't connect to the target DB itself for DROP —
PostgreSQL refuses while a connection is open to it.

Fix: explicitly connect to `postgres` (the maintenance DB every PG
cluster ships with) for the DROP/CREATE statements. Override via
DB_CHECK_DB env var if the `postgres` DB is restricted to superusers
on the cluster — matches the pattern wait-for-db.sh already exposes.

Also quote the database name in the SQL so installs whose DB has
unusual characters (numbers, hyphens) don't break the statement.

Surfaced during Ralf's end-to-end restore validation — yet another
"never been tested on a real PG install" latent bug exposed by the
Stage A inline-dump path actually being able to produce a restorable
manifest for the first time on his install.
2026-05-30 13:06:15 +02:00
Luca 5c0be66a14 fix(restore): resolve local source + always rollback on failure
Two changes that close the disaster-recovery loop the Stage A-B-C
backup-hardening plan opened:

1. Resolve 'local' source to backup_destination_path
   The wizard passes options.source = 'local' (the SOURCE TYPE
   string). The old code assigned that verbatim to localBackupPath
   and every downstream path.join() ended up with junk like
   'local/database/<file>.sql.gz'. Fixed by looking up
   backup_destination_path from app_settings when source='local',
   plus a layered candidate fallback in performDatabaseRestore so
   absolute paths in manifests are honoured first.

2. Auto-rollback on ANY failure during restore
   Previously rollback only fired when post-restore VERIFICATION
   failed (inside the try block). Anything that threw earlier —
   path bugs, pg_restore failure, file copy errors — left the
   destination half-clobbered with no automatic recovery. Now the
   catch block always invokes attemptRollback if a pre-restore
   backup exists, and persists rollback status in
   was_rollback_attempted + an enriched error_message so the admin
   can tell at a glance whether the destination is safe to retry
   on top of or needs manual inspection first.

Surfaced during Ralf's validation of the end-to-end backup +
restore cycle (`docker compose down -v` then restore from disk).
Every prior failed attempt left stray PDFs behind that the next
attempt had to navigate around — exactly the "every failure makes
the next worse" pattern this fix kills.
2026-05-30 12:47:40 +02:00
Luca 44c7935b84 fix(restore): resolve 'local' source to backup_destination_path
Two stacked bugs in the disaster-recovery path:

1. The wizard passes `options.source = 'local'` (the source TYPE
   string) and the service assigned it verbatim to `localBackupPath`.
   Every downstream `path.join(localBackupPath, ...)` ended up with
   junk like `local/database/<file>.sql.gz` and `local/events/...`.

2. performDatabaseRestore reconstructed the dump path from the
   manifest by basename-only:
     path.join(backupPath, 'database', path.basename(dbBackupFile))
   discarding the absolute path the manifest actually recorded.

Cure:
  - At the entry point, if `options.source === 'local'`, look up
    `backup_destination_path` from app_settings and use that as the
    local root. Honour s3:// downloads via the existing branch.
  - In performDatabaseRestore, try the manifest's absolute path
    first, then `localRoot + manifest_value`, then the legacy
    `localRoot + 'database' + basename` reconstruct as a final
    fallback. First hit wins; error message lists every candidate
    so future failures are diagnosable.

Surfaced during Ralf's end-to-end validation of the Stage A-B-C
backup-hardening plan — restored fresh after `down -v`, the wizard
failed silently with `Database backup file not found: local/database/...`
even though the dump existed at the path the manifest recorded.
With this fix, the same destruction-and-recovery sequence completes.
2026-05-30 12:38:59 +02:00
Luca f664fea60c fix(restore): discover backups from disk, not just the DB
The Restore wizard's "Choose Backup to Restore" list was driven only
by the backup_runs table. After `docker compose down -v` (the disaster
this whole hardening effort is designed to recover from), the DB is
empty and the wizard shows "No backups found in selected source" —
exactly when it's needed most. The manifest JSONs are still on disk;
the wizard just can't see them.

Adds disk-first discovery:
  - Walks backup_destination_path AND backup_manifest_path (manifests
    can live in a sibling directory under the canonical
    <root>/manifests/backup-manifest-<id>.json layout). Depth-limited
    recursion (3 levels) so the scan doesn't enumerate the photo tree.
  - Matches backup-manifest-*.json|yaml AND legacy bare manifest.json.
  - Parses each manifest for real metadata (timestamp, size, file
    count, database.backup_file presence) instead of showing the
    admin opaque filenames.
  - Layers in surviving backup_runs rows, deduping by manifest_id.

Applied to both GET /available-backups (legacy) and POST /list-backups
(the one the frontend actually calls). Same helper, two call sites.

Side benefit: each returned row now carries `databaseIncluded` — so a
future Restore UI iteration can show a "this backup has no DB dump"
warning before the admin picks a files-only backup. Exactly the
surface that would have caught Ralf's original four files-only
manifests if it had existed.
2026-05-30 04:07:40 +02:00
Luca ed7ab61b90 fix(admin-ui): backup download button hits API path, not SPA route
`BackupHistory.jsx` opened `/admin/backup/download/<id>` via window.open,
which goes to the React SPA's router — no matching route, so it
rendered the "Page Not Found" screen.

The actual download endpoint lives at `/api/admin/backup/download/:id`
on the backend (adminBackup.js:685). Cookie-based admin auth already
supports the implicit cookie sent by window.open, so the URL prefix
was the only thing missing.

Predates today's backup-hardening work — the bug has existed since
this download button shipped. Surfaced now because Ralf finally has a
completed backup to try downloading after the Stage A inline-dump
guard started working.
2026-05-30 03:51:02 +02:00
Luca 0ad14899fa ix(database-backup): drop bogus --single-transaction flag from pg_dump
pg_dump rejects `--single-transaction` — it's a pg_restore / psql flag,
never a pg_dump one. Triggered as soon as the inline-dump path landed
on Ralf's install:

  pg_dump: unrecognized option: single-transaction
  pg_dump: hint: Try "pg_dump --help" for more information.

pg_dump already wraps the entire export in a single REPEATABLE READ
snapshot automatically (since Postgres 9.x), so the original intent —
consistent snapshot of the live DB — is preserved by removing the
flag. Same "latent until Stage A wired it in" pattern as the three
prior bugs this rollout has surfaced (PG insert destructure → bind-
mount EACCES → Node 22 stdio strict mode → this).
2026-05-30 03:35:08 +02:00
Luca d34036c4ef fix(safe-exec): Node 22-compatible stdio + error-bridge for spawnTo/FromFile
spawnToFile and spawnFromFile passed an unopened WriteStream/ReadStream
directly as a stdio entry to child_process.spawn. Older Node versions
auto-extracted .fd; Node 22 throws synchronously:

  The argument 'stdio' is invalid.
  Received WriteStream { fd: null, path: '/backup/database/...sql', ... }

Bug bit Ralf's install once today's `bugfix/crm-backup` image landed —
Node 22 came with that image, and Stage A's inline-dump path is the
first caller of spawnToFile on this install. Latent on the previous
image (Node 20); fatal on this one. restoreService's pre-restore
safety snapshot uses the same helper and would have hit it next time
a restore ran.

Cure: stdio: ['ignore', 'pipe', 'pipe'] (and ['pipe', 'pipe', 'pipe']
for spawnFromFile) + manual pipe of child.stdout/stdin through the
file stream. Works on every Node version. Also wires the WriteStream's
'error' event to the promise via settleReject so a future EACCES /
ENOSPC reaches the caller's try/catch instead of becoming a process-
fatal unhandled error event — closing the same "Stage A guard
bypassed" hole noted in the spawned follow-up task.

Side benefit: outStream.end() now awaits flush before resolving, so
fast pg_dump runs can no longer produce a truncated dump.
2026-05-30 03:26:10 +02:00
Luca f741e88acb fix(database-backup): Postgres-safe insert destructure (runs the inline dump)
databaseBackupService.backup() did `const [runId] = await db(...).insert({...})`
without a .returning() — works on SQLite (knex returns [lastInsertId]) but
throws "(intermediate value) is not iterable" on Postgres (knex returns
a non-iterable shape).
Bug was latent until Stage A of the backup-hardening plan wired this
method into the "Run Backup Now" inline-dump path. Before Stage A only
the scheduled cron + the dedicated admin-DB-backup page called it, and
Ralf's install had never exercised either — so the inline-dump default
landing in production was the first time the destructure ran on his PG.
Cure: same explicit .returning('id') + dual-shape coalesce pattern that
backupService.js uses for its own backup_runs insert (line 949).
Two more sibling files have the same anti-pattern (userManagementService,
customerAccountsService — invitation flows) and will bite under the
same conditions; spawned a follow-up task to fix them in a separate PR.
2026-05-30 02:56:10 +02:00
Paul Nothaft cc9a1ffa8f Merge pull request #589 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.59.0-beta.0
2026-05-29 22:56:31 +02:00
github-actions[bot] 714f7647fc chore(beta): release 3.59.0-beta.0 2026-05-29 20:54:16 +00:00
Paul Nothaft c4a9b3636f Merge pull request #588 from the-luap/feat/admin-user-activate-delete
feat(admin/users): reactivate + delete actions for deactivated admin users
2026-05-29 22:53:54 +02:00
Paul Nothaft dfcebccee9 feat(admin/users): reactivate + delete actions for deactivated admin users
#574 follow-up — @blazmaric flagged that once an admin user is
deactivated, the UI loses every affordance to manage that record.
The deactivate button hides (rightly — they're already deactivated)
but nothing replaces it, leaving the row stranded in the list with
no path to either restore access or permanently remove it.

## Backend

New on `userManagementService`:

- **`activateAdminUser(id, activatedById)`** — symmetric to
  `deactivateAdminUser`. Flips `is_active` back to true, logs
  `admin_user_activated` activity. Idempotent: already-active target
  short-circuits without bumping `updated_at`. No "can't activate
  yourself" guard needed (actor is by definition already active).
- **`deleteAdminUser(id, deletedById)`** — hard-deletes the row.
  Same self-action and last-super-admin guards as deactivate.
  Last-super-admin guard counts ACTIVE super admins excluding the
  target — so an already-deactivated super_admin can still be
  deleted when an active super_admin remains. FK ON DELETE rules
  in core migrations handle the cascade: SET NULL on
  `created_by_admin_id` everywhere (events, photos, quotes,
  invoices, contracts, customer_accounts, …); CASCADE on the
  user's own `api_tokens` + their pending admin / customer
  invitations.

New routes on `adminUsers.js`:

- `POST /api/admin/users/:id/activate` — `users.delete` permission
  (same tier as deactivate; reverting deactivation is the same
  scope of action as performing it).
- `DELETE /api/admin/users/:id` — `users.delete`.

## Frontend

`UserManagementPage.tsx`:

- New mutation hooks: `activateUserMutation`, `deleteUserMutation`.
- The row's action cell now branches on `user.isActive`: active
  users see Edit + Deactivate (unchanged); deactivated users see
  Edit + Reactivate (`UserCheck` icon, green hover) + Delete
  (`Trash2` icon, red hover).
- The shared `ConfirmDialog` handles all four action types
  (deactivate / activate / delete / cancelInvitation) via per-type
  title / message / confirmText / variant lookup.

`userManagement.service.ts`:

- New `activateUser(id)` and `deleteUser(id)` methods mirroring the
  existing `deactivateUser` shape.

i18n keys are added with English fallbacks via `t(key, fallback)`
so the page works on every locale without a missing-translation
warning. Native translations can be filled in via a follow-up.

## Test plan

- [x] 8 new service tests pin: activate happy-path, idempotency on
  already-active, NotFoundError on missing target, activity log
  emitted, delete self-refusal, last-super-admin guard for both
  active and already-deactivated super_admin targets, hard-delete
  success, delete activity log.
- [x] Frontend type-check clean.
- [x] Frontend lint clean for the changed files.
- [x] Backend lint clean.
- [ ] Manual: deactivate a user → row now shows Reactivate + Delete
  → reactivate → user can log in again. Then deactivate again →
  delete → row vanishes, pending tokens for that user invalidated.

Closes the UX gap blazmaric called out in
https://github.com/the-luap/picpeak/pull/579#issuecomment-... .
2026-05-29 22:49:01 +02:00
Paul Nothaft d32bdda1b3 Merge pull request #586 from the-luap/feat/crm-route-tests-570
test(crm): HTTP route tests for CRM public + admin surface
2026-05-29 22:42:03 +02:00
Paul Nothaft 5c4da1eacd test(crm): HTTP route tests for CRM public + admin surface (#570)
Closes #570.

PR #555 shipped the CRM module with strong service-layer coverage
but no HTTP-layer tests. This adds Supertest-based route coverage
across the externally-reachable public routes (P0) and an auth-gate
sweep of every CRM admin route (P1+P2).

## What's covered

### P0 — Public routes (49% of new tests)

The three public routes are the security-sensitive surface — any IP
with the raw token from a leaked email can hit them. Tests pin the
publicTokenGuards.loadActionToken contract end-to-end:

- **publicQuotes** (8 tests) — GET load + POST respond: 404 unknown,
  400 malformed, 410 expired, 200 valid w/ sanitised payload (no
  customer_account_id / created_by_admin_id leakage), 429 after 20
  bad attempts (IP lockout), 400 invalid action.
- **publicContracts** (10 tests) — GET load + POST sign + POST
  upload-signed-pdf + GET pdf: same guard outcomes per endpoint,
  plus the pre-multer token check (malformed token rejected before
  multer reads the body — prevents the disk-spam attack the
  preMulterTokenGuard was added for).
- **publicPaymentCheck** (6 tests) — different shape (no
  loadActionToken; service does its own validation): validator gate
  on token shape, all 4 canonical actions pass through the
  validator, negative amountMinor rejected.

The NULL-expires_at defensive branch in loadActionToken is
documented but not tested here — current schema declares
quote/contract_action_tokens.expires_at NOT NULL, so the branch is
unreachable at the route level. Worth a direct unit test on
loadActionToken if anyone wants to cover it.

### P1 + P2 — Admin routes (51% of new tests, 25 cases)

One consolidated `adminCrmAuth.test.js` file rather than nine
per-route files — the auth-gate contract is identical for every CRM
admin route, so a parametrised `describe.each` is more efficient
and lands the same coverage:

Per route (adminQuotes, adminContracts, adminInvoices, adminCalendar,
adminDeals, adminTaxReport, adminBusinessProfile):
- 401 without Authorization header (adminAuth gate)
- 401 with invalid JWT signature (adminAuth signature check)
- 2xx with super-admin token + CRM feature flags on (permission +
  feature-flag gates both pass)

Plus 4 tests for the CRM additions in adminCustomers
(hour-entries / bill / trigger-monthly-bill) — those endpoints
are mixed in with pre-existing customer routes, so they get
explicit coverage rather than bulk via the parametrised sweep.

## Harness extensions to integration/helpers/crmDb.js

Three new helpers (one place for any future route test to find):

- `mintAdminToken(adminId, opts)` — JWT signed with the test
  JWT_SECRET, shape matches what adminAuth expects.
- `createPublicToken(db, tableName, opts)` — insert a row into
  quote/contract_action_tokens with controllable expires_at /
  used_at / token. Note: Date values are explicitly ISO-stringified
  before insert — bare Date objects round-tripped inconsistently
  through knex+SQLite, sometimes via .toString() → literal
  `"[object Object]"` which parsed back to NaN and silently defeated
  the expiry guard. Caught it in test bring-up.
- `buildRouteApp(mount, router)` — minimal Express app (json + cookies)
  with a catch-all error handler that mirrors middleware/errorHandler
  (uses err.statusCode, not err.status — getting that wrong silently
  maps every 4xx to 500 in tests).
- `assignAdminRole(db, adminId, roleName)` — promotes a seedMinimal
  admin into super_admin (or any seeded role) for happy-path tests.

## Out of scope (follow-up)

Deeper integration tests for the document mint/send paths
(adminQuotes.send → PDF persisted + token minted + email queued;
adminInvoices.Storno → new row with shared deal_uuid + original
cancelled; adminContracts.countersign → integrity_hash computed)
are deferred. The service-layer behind those is already covered by
the existing __tests__/services/ suites — this PR pins the
HTTP-layer contract, which is what #570 actually asked for.

## Counts

- 4 new test files, 49 tests total
- ~860 LOC of test code + ~85 LOC of new harness in crmDb.js
- All tests pass in <2.5s (no real network, no real disk except the
  per-test tmpdir, no email sending)
2026-05-29 22:36:59 +02:00
Luca 03e6617f38 feat(backup): coverage diagnostic — what will the next backup miss?
Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
  - new backupCoverageService.js: per-path coverage classification,
    drift detection (top-level subdirs not in backup_paths and not
    in the backups/tmp allow-list), DB-dump mode + staleness block
  - new GET /api/admin/system-health/backup-coverage route, same
    auth + settings.view permission as /backup-integrity
  - 7 integration scenarios pinning the classifier behaviour
Frontend:
  - new BackupCoverageCard with auto-fetch (cheap; no recursion)
  - new Coverage tab on BackupManagement next to Integrity
  - en + de i18n; other locales fall back to en keys until a native
    speaker reviews
Verification:
  - 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
    Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
  - frontend build clean
  - 4 pre-existing integration failures confirmed unrelated
2026-05-29 22:20:32 +02:00
Paul Nothaft 97d0a4bf7e Merge pull request #585 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.58.0-beta.0
2026-05-29 22:15:50 +02:00
Luca 302fc6b937 feat(backup): config-driven walker via backup_paths table
Stage B of the three-stage backup-hardening plan (Stage A:
inline-DB-dump + fail-loud guard already landed). The file-backup
walker used to hard-code its subdirectory list inside
`getFilesToBackupInternal`, which is the same footgun that hid the
`business-docs` gap for ~6 months — a new feature drops artefacts
under STORAGE_PATH and the maintainer has to remember to edit the
walker.

Now driven by a `backup_paths` table:

  - Migration 108 creates the table and seeds the 7 canonical
    defaults (events/active, events/archived, thumbnails, previews,
    heroes, uploads, business-docs). Seed data lives on the
    migration as `DEFAULT_PATHS` so the boot self-heal can re-use it.
  - `_backupPathsBoot.js` mirrors `_emailTemplateBoot.js`: on every
    boot it diffs the canonical list against the current rows and
    `INSERT ... ON CONFLICT DO NOTHING`s the missing ones. Keeps
    admin edits intact, picks up new defaults shipped after the
    install (Knex won't re-run migration 108). Wired into server.js
    just before `startBackupService()`.
  - Walker now calls `resolveBackupPaths(config)` which:
      * reads `backup_paths WHERE include_in_default=true ORDER BY
        display_order`
      * falls back to a hard-coded `LEGACY_BACKUP_PATHS` if the
        table is missing OR empty (defense in depth — never silently
        scans nothing)
      * gates each row by its `feature_flag` column (matches how
        `backup_include_archived` already worked; data-driven now)
  - Backward compatible: `getFilesToBackup(true|false)` still works
    for legacy callers and the existing businessDocs test. New
    callers should pass the full config object so feature gates
    other than `backup_include_archived` evaluate correctly.

Tests:
  - new: `backupService.configurableWalker.test.js` — 7 cases
    covering canonical seed, toggling include_in_default, runtime
    INSERT picked up without restart, feature_flag gating both on
    and off, empty-table → LEGACY fallback, boolean backward compat
  - all 15 backup-walker integration tests pass
    (configurableWalker 7 + inlineDbDump 5 + businessDocs 3)
  - frontend build clean
  - 4 pre-existing integration failures (webhookDelivery, storage
    backend, adminPhotos.reference, imageProcessor.storage) confirmed
    unrelated via `git stash` baseline run

Stage C (CRM feature coverage audit + diagnostic UI) follows
in a separate commit.
2026-05-29 22:09:23 +02:00
github-actions[bot] d50edcc427 chore(beta): release 3.58.0-beta.0 2026-05-29 20:05:46 +00:00
Paul Nothaft 433af15146 Merge pull request #582 from the-luap/feat/slovenian-locale-580
feat(i18n): add Slovenian (sl) language support
2026-05-29 22:05:32 +02:00
Paul Nothaft f4609f80ef Merge pull request #584 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.57.2-beta.0
2026-05-29 22:05:13 +02:00
github-actions[bot] 4989c468a8 chore(beta): release 3.57.2-beta.0 2026-05-29 20:04:53 +00:00
Paul Nothaft 1ed48046cb Merge pull request #581 from the-luap/docs/crm-readme-mention
docs: list CRM under Beta Features + note dev-compose rebuild gotcha
2026-05-29 22:04:40 +02:00
Paul Nothaft 2908cadf77 Merge pull request #583 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.57.1-beta.0
2026-05-29 22:04:27 +02:00
github-actions[bot] 7be484eb8e chore(beta): release 3.57.1-beta.0 2026-05-29 20:03:02 +00:00
Paul Nothaft de9a924c77 Merge pull request #579 from the-luap/fix/email-normalization-574
fix(email): preserve dots + subaddresses across all normalization sites
2026-05-29 22:02:40 +02:00
Luca 7fdf01ad21 fix(backup): inline DB dump + fail-loud guard so "Run Backup Now" can't ship files-only
The previous file-backup workflow only LOOKED UP an existing
database dump via getDatabaseBackupInfo() and silently shipped a
files-only manifest when none was found. Admins clicking "Run
Backup Now" (or relying on the schedule) got an apparent success
that omitted every customer / quote / invoice / contract / payment-
log row. The data-loss footgun was discovered 2026-05-29 when an
admin who'd been "backing up" for weeks via the UI lost the entire
CRM after a routine docker compose down -v — every produced
manifest had database: { backup_file: null, size: 0, tables: {} }.
New helper `ensureDatabaseDumpForBackup(config)` encapsulates:
  1. Inline pg_dump (or SQLite copy) before the file scan, via
     databaseBackupService.backup(). Result lands in
     database_backup_runs and is picked up by the existing
     getDatabaseBackupInfo lookup that writes the manifest.
  2. Fail-loud guard: if no usable dump file is reachable (path
     missing, 0 bytes, or never existed), throw — the existing
     catch in runBackupInternal marks the backup_runs row failed
     with the error_message and emails the admin if configured.
     No more silent files-only manifests.
  3. Opt-out: `backup_database_inline_dump = false` skips the
     inline dump for admins who already run their own scheduled
     `backup_database_schedule`. The fail-loud guard still
     applies, so an opted-out install with no recent dump still
     aborts loudly instead of producing a partial backup. Default
     ON is encoded as "skip only when explicitly false" — undefined
     (existing installs upgrading) falls through to the safe-
     default ON branch.
The helper returns the verified `databaseInfo` so the manifest-build
step at runBackupInternal:917 reuses it instead of calling
getDatabaseBackupInfo a second time. S3/future destinations that
override `result.databaseInfo` are still respected (the existing
`result.databaseInfo ||` fallback shape stays put).
Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.
Stage A of three-stage backup hardening plan. Stage B (config-
driven walker) and Stage C (audit + diagnostic UI) follow in
separate commits.
2026-05-29 22:00:15 +02:00
Luca 7c230bdc24 fix(backup): inline DB dump + fail-loud guard so "Run Backup Now" can't ship files-only
The previous file-backup workflow only LOOKED UP an existing database
dump via getDatabaseBackupInfo() and silently shipped a files-only
manifest when none was found. Admins clicking "Run Backup Now" (or
relying on the schedule) got an apparent success that omitted every
customer / quote / invoice / contract / payment-log row. The
data-loss footgun was discovered 2026-05-29 when an admin who'd been
"backing up" for weeks via the UI lost the entire CRM after a routine
docker compose down -v — every produced manifest had database:
{ backup_file: null, size: 0, tables: {} }.

Changes to runBackupInternal:

  1. Inline pg_dump (or SQLite copy) before the file scan, via
     databaseBackupService.backup(). Result lands in
     database_backup_runs and is picked up by the existing
     getDatabaseBackupInfo lookup that writes the manifest.

  2. Fail-loud guard after the dump step: if no usable dump file is
     reachable (path missing, 0 bytes, or never existed), throw —
     the existing catch block marks the backup_runs row failed with
     the error_message and emails the admin if configured. No more
     silent files-only manifests.

  3. Opt-out: `backup_database_inline_dump = false` skips the inline
     dump for admins who already run their own scheduled
     `backup_database_schedule`. The fail-loud guard still applies,
     so an opted-out install with no recent dump still aborts loudly
     instead of producing a partial backup. Default ON is encoded
     as "skip only when explicitly false" — undefined (existing
     installs upgrading) falls through to the safe-default ON path.

Test suite covers: default-on happy path, dump-throws-aborts-run,
opt-out + recent dump + proceeds, opt-out + no-dump + fail-loud,
opt-out + 0-byte dump + fail-loud. Mocks
databaseBackupService.backup so the tests don't depend on pg_dump
or sqlite3 CLI binaries being installed.

Stage A of three-stage backup hardening plan. Stage B (config-driven
walker) and Stage C (audit + diagnostic UI) follow in separate
commits.
2026-05-29 21:57:12 +02:00
Paul Nothaft 37cc3631d8 feat(i18n): add Slovenian (sl) language support
Closes #580.

Slovenian community contribution from @blazmaric (filed as an issue
with attached files rather than as a PR — files inlined here unchanged
except for the migration number).

## Changes

- **`frontend/src/i18n/locales/sl.json`** — full Slovenian UI
  translations. Covers every top-level key present in `en.json` as
  of pre-CRM beta. The new CRM-module keys (`bills`,
  `businessProfile`, `calendar`, `contracts`, `crm`, `crmDev`,
  `crmSettings`, `dealLineage`, `eventReminderOverride`,
  `hoursLogging`) are not yet translated and will fall back to
  English — same posture as FR / NL / PT / RU / ES currently have
  for the CRM module (see PR #555 description).
- **`frontend/src/components/common/LanguageSelector.tsx`** — adds
  `SLFlag` SVG component + registers `{ code: 'sl', name:
  'Slovenščina', Flag: SLFlag }` in `SUPPORTED_LANGUAGES`. Frontend
  i18n auto-discovers locale files via `import.meta.glob` so no
  separate config registration is needed.
- **`backend/migrations/core/108_seed_sl_email_template_translations.js`** —
  contribution-author's `107_*` filename renumbered to `108_` to
  avoid collision with `107_crm_consolidated.js` that landed on beta
  in the meantime. Idempotent insert via (template_id, language)
  uniqueness check — re-runnable, never overwrites admin edits.
  Covers 17 templates: admin invitation / password reset, archive
  complete, backup completed / failed, customer gallery assigned,
  customer invitation / password reset, database backup completed /
  failed, expiration warning, gallery created / expired, restore
  completed / failed, version update available / test.
- **`backend/src/services/emailProcessor.js`** — adds `.si → sl` to
  the email-domain → language inference map, matching the pattern
  for every other supported locale. A customer with `@example.si`
  now gets Slovenian emails automatically without needing to set
  their preferred_language explicitly.

## Out of scope (consistent with existing locales)

- CRM email templates (quote_sent, invoice_sent, contract_sent, etc.,
  seeded at boot by `crmEmailTemplates.ensureCrmEmailTemplatesSeeded`)
  will fall back to English for Slovenian customers — those seeders
  only emit EN + DE rows today across every locale.
- CRM UI strings under the missing top-level keys listed above will
  fall back to English.

Both gaps mirror the existing FR / NL / PT / RU / ES situation.
2026-05-29 21:54:55 +02:00
Paul Nothaft 975a815f99 Merge branch 'beta' into fix/email-normalization-574
Resolves a conflict with the CRM merge (#555) that landed on beta
between when this branch was cut and now.

Two conflict regions in backend/src/routes/adminCustomers.js:

1. **Require block** — both branches added new requires after
   customerAccountsService. Kept both: this branch's
   emailNormalization import AND beta's customerHoursService +
   invoiceService imports (the CRM merge added the hours-billing +
   invoice-creation paths to this router).

2. **Edit-customer validators** — both branches changed the same set
   of body() validators in the PUT /:id handler. This branch added
   the IDENTITY_PRESERVING_NORMALIZE_EMAIL options arg to
   normalizeEmail; beta changed every body() to optional({ nullable:
   true }) so passive-customer records that store nulls for missing
   profile fields don't reject on save. Kept both: the nullable
   pattern from beta + the email-normalization options from this
   branch. Preserved beta's explanatory comment about the nullable
   choice.

Also patched one NEW normalizeEmail site the CRM merge introduced:

- backend/src/routes/adminCustomers.js:231 — POST /admin/customers
  now exists (CRM-era customer-create endpoint). Same options arg
  applied.

backend/src/routes/adminBusinessProfile.js has an isEmail() WITHOUT
normalizeEmail() on the issuer email — intentional (no normalization
means no risk of the Gmail dot-strip bug for that field), no change
needed.

All 18 normalizeEmail sites now pass IDENTITY_PRESERVING_NORMALIZE_EMAIL.
7/7 regression tests still pass. Lint clean on the merged file.
2026-05-29 21:48:42 +02:00
Paul Nothaft 2692e71297 docs(contributing): note rebuild-after-package.json gotcha for dev compose
When PR #555 (CRM module) added pdfkit/swissqrbill/pdf-lib/qrcode to
backend/package.json, every dev with an already-built dev image hit
a MODULE_NOT_FOUND restart loop on the next pull. Root cause: the dev
compose bakes node_modules into the image while live-mounting src/
from disk — a dep added on disk isn't visible to the running container
until the image is rebuilt.

The symptom doesn't point at the cause, so this adds a short rebuild
note to the Local Development section of CONTRIBUTING.md. A
self-healing entrypoint (compare node_modules/.package-lock.json
vs /app/package-lock.json on boot, npm ci if they differ) would fix
this at the runtime layer too; tracked as a follow-up.
2026-05-29 21:43:49 +02:00
Luca 5b3bfed144 revert(docker): drop /backup chown from wait-for-db.sh
The fix shipped in 3ab3756 added /backup to the boot-time chown list.
That broke installs that don't bind-mount ./backup:/backup — the
single greedy `chown -R /a /b /c /backup` returned non-zero on any
individual failure, exiting the script and putting the backend into
a restart loop.

Reverting to the upstream-stable version. The original EACCES at
backup time is better fixed by admins pointing the backup destination
at a writable path via the admin UI (e.g. /app/storage/backups,
which the script already chowns) rather than baking a /backup
assumption into every install's boot path.
2026-05-29 18:05:40 +02:00
Luca 3ab3756a56 fix(docker): chown /backup mount to nodejs on container startup
The docker-compose `./backup:/backup` mount was the only bind mount
not included in wait-for-db.sh's startup chown step. On a fresh
install (or any time the mount point is recreated), it stays
owned by root, and the nodejs (UID 1001) process running the
backup service gets EACCES when trying to mkdir under /backup.

Added /backup to both the chown list (root branch) and the
writable-check list (compose `user:` override branch), each guarded
by `[ -d /backup ]` so installs that don't use the bind mount —
native deployments, k8s with a different backup destination, etc. —
still boot cleanly.

Existing installs hit by this need a one-time host-side
  sudo chown -R 1001:1001 <host-mount-for-/backup>
because the on-disk ownership won't fix itself; the script only
chowns at startup, and the directory was already created with
the wrong ownership by Docker's mount-point auto-creation. From
this commit onward, fresh installs are correct from the first
boot.
2026-05-29 16:42:52 +02:00
Paul Nothaft c2dcd9ca84 docs(readme): list CRM module under Beta Features with own-risk disclaimer
PR #555 shipped the CRM module on beta. The README's "Beta Features
(Use at your own risk)" table is the right place to signal that the
feature exists, is opt-in, and carries non-trivial legal / financial
caveats — readers landing on the README should not first discover the
CRM by enabling its feature flags and bumping into the seeded
example contract bodies without warning.

Adds one row to the Beta Features table linking to
docs.picpeak.app/features/crm where the full disclaimers,
sub-feature pages, and admin-settings reference live.

CRM is intentionally NOT added to the top-of-README "Key Features"
list — those are stable, production-ready features. Mixing the beta
CRM in there would undermine the clear stable/beta distinction.
2026-05-29 16:39:11 +02:00
Paul Nothaft fd61416665 Merge pull request #578 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.57.0-beta.0
2026-05-29 16:26:46 +02:00
github-actions[bot] 463ef4d0fd chore(beta): release 3.57.0-beta.0 2026-05-29 14:25:27 +00:00
Paul Nothaft e537923857 Merge pull request #576 from the-luap/docs/release-cadence-565
docs(release): establish stable-channel cadence + promotion process
2026-05-29 16:24:59 +02:00
Luca 3f5d006625 fix(test-infra): scope databaseBackup fs.unlink stub so it doesn't leak
Line 205 of databaseBackup.test.js reassigned `fs.unlink` directly
(`fs.unlink = jest.fn(...)`), which permanently mutated the global
fs.promises module. Every test running after this in the same jest
worker process inherited the no-op stub, including
integration/storageBackend.test.js — whose LocalFsStorage.delete()
silently became a no-op, making the subsequent exists() assertion
flip from false to true.

Confirmed by adding a diagnostic patch to LocalFsStorage.delete:
post-await fsp.unlink, fs.existsSync(abs) returned true. unlink had
resolved without throwing but the file was still there → the unlink
was a mock.

Fix: jest.spyOn(fs, 'unlink').mockResolvedValue(undefined) + a
matching mockRestore() at the end of the test. Behaviour is
identical inside this test; the original fs.unlink is restored
when the test finishes, so subsequent tests get real fs.unlink
again.

Pre-existing issue — has been latent on upstream/beta forever.
Only surfaces consistently when CI load shifts jest's worker
allocation such that databaseBackup and storageBackend land in
the same worker process. This PR's extra integration test files
made that allocation deterministic locally and frequent enough on
CI to fail reliably.
2026-05-29 16:21:15 +02:00
Luca ecb2aeacf9 fix(test-infra): unref sessionTimeout cleanup interval so workers exit gracefully
The 5-minute session-sweep interval at sessionTimeout.js:17 fired at
module-load time without .unref(), so every jest worker that
transitively required this module (server.js → middleware → most
of the route layer) kept the event loop alive forever. The worker
then got force-killed on shutdown, surfacing as the longstanding
"worker failed to exit gracefully" warning at the end of every CI
run on upstream/beta.

Under enough I/O / memory pressure on a CI runner, the force-kill
could land MID-test rather than after the suite finished, taking
out whatever else was running on that worker — most visibly
integration/storageBackend.test.js on PR #555's runs.

.unref() makes the timer not keep the loop alive on its own.
Production behaviour is unchanged: the timer still fires every
5 min as long as anything else is holding the loop open (the HTTP
server, always).
2026-05-29 15:41:58 +02:00
Luca 614c8b9b8f test(backup-integrity): tolerate both knex .returning('id') return shapes
CI's SQLite returned `[N]` (plain int) from `.insert().returning('id')`
while local SQLite returned `[{ id: N }]` (object form). The brittle
`const [{ id }] = ...` destructure crashed on the int shape. Switched
to the unwrap pattern used by the existing crmDb test harness so the
suite runs on both PG and every SQLite/knex combo the project supports.
2026-05-29 13:25:36 +02:00
Luca 7e2feca12f feat(backup): UI for backup-integrity verifier — tab + post-restore CTA
Frontend half of the diagnostic shipped in 4812fcd. Adds:
  - BackupIntegrityCard component — runs the check on demand, surfaces
    the five summary counters (total / verifiedOk / existsButNoHash /
    missing / hashMismatches), and expands collapsible result tables
    for missing files + hash mismatches. existsButNoHash is exposed as
    a separate amber-toned bucket so admins can distinguish hash-
    verified evidence from existence-only at a glance — the latter is
    explicitly weaker in a legal dispute and the UI says so.
  - "Integrity" tab on BackupManagement, alongside the existing
    Dashboard / Configuration / History / Restore tabs. Card is
    portable — when the System Health page (backlog item) lands it
    can lift the component without changes.
  - Post-restore CTA on the RestoreWizard success card (D2 follow-
    through): "Verify document integrity now" button that switches
    the parent tab to Integrity. The audit trail captured at sign /
    issue time is worth nothing if the documents it refers to are
    missing from the restored copy — verifier surfaces that drift
    in one click before the admin trusts the restored state.
i18n strings added in EN + DE (per user_languages — only those two
are native; other locales fall back to the English defaults and
should be flagged for native-speaker review per
feedback_translation_flagging if anyone picks them up).
2026-05-29 13:11:43 +02:00
Luca 4812fcdec3 feat(backup): admin endpoint to verify CRM document-artefact integrity
Diagnostic for the bug fixed in a9280ea — confirms every *_path
column on quotes / contracts / invoices points at a file that
actually exists on disk and (where a *_sha256 column is set) the
file's bytes still hash to the expected value. Read-only;
on-demand only; no scheduler.
Per the design decisions locked in this PR's design call:
  D1 — on-demand only for v1; scheduling deferred until we have
       runtime data on large installs
  D2 — not auto-triggered after restore; surface a "verify
       integrity now" CTA on the restore-completed screen instead
  D3 — wet-upload contracts hash-verified same as system-rendered
       (signed_pdf_sha256 is computed at upload time, no special
       case needed in the verifier)
Coverage (single source of truth in backupIntegrityService.CHECKS):
  quotes.pdf_path                           existence
  contracts.pdf_path + pdf_sha256           existence + hash
  contracts.signed_pdf_path + signed_pdf_sha256  existence + hash
  contracts.signed_customer_signature_path  existence  (PNG/JPG, no hash)
  contracts.signed_admin_signature_path     existence  (PNG/JPG, no hash)
  invoices.pdf_path                         existence
  invoices.imported_pdf_path                existence  (admin-uploaded scans)
Report shape buckets each row into verifiedOk / missing /
hashMismatches / existsButNoHash so callers can distinguish hash-
verified from existence-only — the latter is weaker evidence in
a legal dispute and the UI should reflect that.
Route GET /api/admin/system-health/backup-integrity accepts an
optional ?scope= CSV filter (quote | contract | contract-signature
| invoice). Unknown scope tokens are rejected with a 400 +
BACKUP_INTEGRITY_UNKNOWN_SCOPE code rather than silently scanning
everything.
Frontend half (BackupIntegrityCard on a System Health page) is
deferred until backlog #11 (System Health page) is scaffolded.
The endpoint is independently useful via curl in the meantime.
2026-05-29 13:00:18 +02:00
Luca a9280ea9ba fix(backup): include storage/business-docs/ in the in-app backup walker
backupService.getFilesToBackupInternal() enumerated a fixed list of
storage subdirectories (events/active, events/archived, thumbnails,
previews, heroes, uploads) and silently omitted the entire
business-docs/ tree. Every CRM PDF artefact and signature image fell
outside the in-app scheduled backup — restoring the DB without the
PDFs would have left every *_path column on quotes/contracts/invoices
as a broken FK and lost forensic evidence (the customer signature
PNG/JPG drawn on the public signing page is referenced by
contracts.signed_customer_signature_path; the rendered contract PDF
is referenced by signed_pdf_path with a stored signed_pdf_sha256
that would have nothing to verify against; wet-uploaded contracts
and admin-imported historical invoices are irrecoverable by design
since no renderer can reproduce them).
Single new scanDirectory call after the existing uploads scan,
covering:
  - business-docs/quote/<year>/*.pdf
  - business-docs/contract/<year>/*.pdf
  - business-docs/contract/signatures/<contract_id>/*.{png,jpg}
  - business-docs/invoice/<year>/*.pdf
  - business-docs/invoice-imports/<year>/*.pdf
  - and incidentally business-docs/dev-test/ (managed by adminDev.js,
    bounded to 7 newest files, harmless to back up)
Verified that no migration is needed: hasFileChanged returns
!existing || checksum mismatch, so the first backup after this lands
flags every business-docs/** file as new and copies it. Restore path
in restoreService.performFilesRestore uses fs.mkdir({ recursive:
true }) on path.dirname(targetPath), so business-docs subdirectories
are recreated automatically from manifest entries — no restore-side
code change required.
Integration test pins the contract so a future refactor cannot
silently drop business-docs again.
The shell-script backup at scripts/backup.sh already covered all of
this via blanket `tar -czf storage`; only the in-app service was
affected.
2026-05-29 12:50:02 +02:00
Paul Nothaft 075b45f020 fix(email): preserve dots + subaddresses across all normalization sites (#574)
Closes #574.

Reporter (@blazmaric) identified the root cause cleanly:
express-validator's `.normalizeEmail()` applies provider-specific
canonicalization by default — Gmail dot-stripping, +tag stripping,
googlemail → gmail folding, etc. That's wrong for identity: PicPeak
uses email as a login identifier, so `john.doe@gmail.com` getting
silently stored as `johndoe@gmail.com` means the user can't log in
with the address they were invited with.

The bug existed at 17 call sites across the codebase (auth, admin user
create/update, customer create/update, event create/update on three
different routes, customer login, feedback submission). All of them
are identity-bearing — none had a legitimate reason to strip dots
for deduplication.

Fix: introduce one shared options object in `utils/emailNormalization`
disabling every provider-specific normalization
(gmail_remove_dots, gmail_remove_subaddress,
gmail_convert_googlemaildotcom, outlookdotcom_remove_subaddress,
yahoo_remove_subaddress, icloud_remove_subaddress). The only default
left enabled is `all_lowercase`, which is safe — local-parts are
case-insensitive in practice on every major provider, and lowercasing
keeps login lookup consistent.

Every call site updated to pass the shared options. 7 unit tests pin
the preserved-dots, preserved-subaddress, preserved-googlemail-domain,
and still-lowercase behaviours so a future refactor can't silently
regress.

## Migration note

Existing accounts whose emails were already stripped before this fix
remain with the stripped form in the DB. The fix takes effect for new
invitations going forward. If an admin re-invites an existing user
with the un-stripped address, that would create a duplicate account —
out of scope here; if it becomes a real problem we can add a
backward-compat login fallback (try lookup with dot-stripped form too)
as a separate change.
2026-05-29 11:42:25 +02:00
Paul Nothaft 48cf1121e5 Merge pull request #575 from the-luap/feat/clickable-version-links-566
feat(admin): clickable version links + update-available modal with changelog & upgrade command
2026-05-29 11:35:06 +02:00
Paul Nothaft 43fe0eb70d Merge pull request #577 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.56.0-beta.0
2026-05-29 11:34:48 +02:00
github-actions[bot] 84486c65f1 chore(beta): release 3.56.0-beta.0 2026-05-29 09:27:47 +00:00
Paul Nothaft 5f0fcc225c Merge pull request #555 from Luca-Timo/feat/crm-pr
feat: CRM module — quotes, contracts, invoices, hours, calendar, tax
2026-05-29 11:27:23 +02:00
Paul Nothaft 832f7bad45 feat(admin): update-available modal with aggregated changelog + upgrade command (#567)
Closes #567.

The sidebar already had a "vX.Y.Z available" indicator (#566 made it a
link to that release's page) but there was no way to read the actual
changelog inline or to grab a copy-paste upgrade command. This adds
the modal the issue spec'd, layered on top of the existing
updateCheckService / environmentService backend infrastructure that
already shipped.

## Backend

- `updateCheckService.fetchAvailableVersions` now returns full release
  objects (tag, name, body, publishedAt, htmlUrl) instead of just
  version strings — body data is what the changelog modal renders.
  `checkForUpdates` extracts the version strings for its existing
  consumers; no API change visible to callers.
- New `getReleasesSince(currentVersion, channel)` returns the list of
  releases strictly newer than current, filtered to the user's
  channel. Reuses the same 1-hour cache as `checkForUpdates` so the
  modal opening doesn't trigger an extra GitHub round-trip.
- New `GET /admin/system/updates/changelog` route in `adminSystem.js`,
  same auth + UPDATE_CHECK_ENABLED gating as the existing
  /updates and /updates/instructions endpoints.
- 4 unit tests (axios mocked) pin: strictly-newer filtering,
  channel-scoped, empty array on GitHub fetch failure, empty array
  when already on latest.

## Frontend

- New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two
  sections:
    1. **How to upgrade** — fetches /updates/instructions for the
       environment-detected copy-paste command (Docker compose / git /
       standalone). Copy-to-clipboard button per step.
    2. **Release notes** — fetches /updates/changelog for every
       version between current and latest in the user's channel.
       Latest is auto-expanded; older releases are collapsed by
       default (click to expand). Each release also has a "View on
       GitHub" link to the canonical release page.
  - Renders release body markdown through the existing safe
    MarkdownContent component (marked + DOMPurify allowlist).
- New `updateDismissal.ts` helper — single localStorage key holds the
  last-dismissed version. Chip stays hidden until a STRICTLY newer
  version appears, using the same compare semantics as the backend
  (stable > beta, higher beta > lower beta, semantic numeric on
  major.minor.patch). 9 unit tests pin the rules.
- `VersionInfo.tsx` — chip is now a button that opens the modal
  instead of an external link (the #566 link-to-release behaviour is
  preserved on the modal's per-release "View on GitHub" affordance).
  Dismissal triggers an immediate re-render so the chip disappears
  without waiting for the next route change.

No new dependencies — uses `marked` + `DOMPurify` that were already
present in the bundle for the contract block renderer.
2026-05-29 11:26:24 +02:00
Paul Nothaft ab81998996 docs(release): establish stable-channel cadence + promotion process (#565)
Closes #565.

Beta has been the de-facto stable channel because the actual stable
lagged so far behind that new users following the README ended up
worse off than users who knew to switch to beta. The fix has two
parts: regular stable cuts (the PR #568 promotion is the first one)
and a written process so future cuts don't depend on memory.

This adds:

- RELEASING.md at the repo root — full operational doc with cadence
  target (4–6 weeks), promotion criteria (CI green + 7-day bug soak
  + upgrade-walk on real-shaped data + operator smoke), the actual
  beta→main mechanics including the conflict-resolution checklist we
  used in PR #568, hotfix backport path (with PR #412 as the worked
  example), and the project's versioning rules.
- CONTRIBUTING.md — replaces the four-line "Release Process" stub
  (which was wrong; it described a hand-rolled flow that release-please
  has handled for the last several releases) with a brief summary and
  a pointer to RELEASING.md.
- README.md — one-sentence addition to the existing "Release Channels"
  section pointing curious users at RELEASING.md.

No code change. CHANGELOG.md and version files are intentionally
untouched — release-please will catch this on the next regular cut.
2026-05-29 11:18:45 +02:00
Paul Nothaft d231623c59 feat(admin): link version numbers in sidebar to GitHub release notes (#566)
Closes #566.

The admin sidebar showed the running frontend + backend versions as
plain text. Wraps each version (and the "update available" indicator)
in an anchor pointing at the corresponding GitHub release tag, opening
in a new tab so the admin session isn't disrupted.

A small githubReleaseUrl helper (extracted to its own module for
testability) does the version → URL mapping. Because release-please
tags every release as `vX.Y.Z[-beta.N]`, the version string already
carries the channel suffix and a pure template covers both stable and
beta without branching.

Three unit tests pin the URL template — stable, beta-with-suffix, and
a defensive check that the leading `v` isn't double-prefixed if a
caller accidentally passes a tag-shaped value.
2026-05-29 10:25:12 +02:00
Luca d1aecaa180 fix(crm): thread trx through sequence-claim sites to unblock SQLite
Reviewer feedback on #555: nextQuoteNumber inside createQuote's
db.transaction was called without passing the outer trx, so
claimNextSequence opened its own connection — Postgres tolerated this
via the pool, SQLite (1-connection default) deadlocked on every quote
creation.
Audited the same pattern across invoiceService + contractService and
found five more matching call sites:
  - createInvoice (single-row path after installment auto-route)
  - spawnInstallmentInvoices (per-sibling claim inside the loop)
  - createStorno
  - createContract
  - createFromQuote
All now thread trx through to nextXxxNumber → claimNextSequence so
the claim joins the caller's transaction on both engines.
convertToInvoiceOnly's Path B (standalone-contract) is the lone
remaining nextInvoiceNumber() call without trx — that path isn't
wrapped in a transaction at all (separate concern: sequence-number
leak on insert failure, tracked separately).
2026-05-27 22:08:38 +02:00
Luca 5ce0b6edc3 fix(quote-response): compute minutes-remaining for the DE changeWithin string
Previous fix (45f0606) papered over the bug by changing the German
wording from "innerhalb von {{minutes}} Minuten" to "bis {{at}}" —
that worked but changed the UX intent. The original German wording
("you have N minutes left") was deliberate and clearer than an
absolute clock time; the actual bug was that no caller ever computed
`minutes` from `responseLockedAt`.

Revert the DE translation to its original wording, then build a
{ at, minutes } object at the call site so EN ("until {{at}}") and
DE ("innerhalb von {{minutes}} Minuten") each pick up the variable
they need. `minutes` rounds UP so a 14m 32s remainder displays as
"15 Minuten" rather than promising 14 the customer can't actually
hit.
2026-05-27 16:06:35 +02:00
Luca 45f0606ea3 fix(i18n): align quoteResponse.changeWithin DE placeholder with call site
The German string used `{{minutes}}` while the call site at
QuoteResponsePage.tsx:300 passes `{ at: <localized time> }`, matching
the English string's `{{at}}`. Result on the public quote page when
the customer had already responded: the literal text "{{minutes}}"
rendered instead of the unlock time.

Switched the German wording to match the English semantics
("until X:XX") since the underlying value is an absolute time, not a
minutes-remaining count — the previous DE wording was also wrong about
WHAT the variable meant.
2026-05-27 15:38:35 +02:00
Luca 83933baeec fix(crm): self-heal missing CRM email templates at boot + recover queue
The CRM template seeders (crmEmailTemplates / contractEmailTemplates /
eventReminderTemplates) were idempotent and ready, but only
contractEmailTemplates was actually called (lazily, by contractService
sends). crmEmailTemplates had no caller anywhere — every install that
didn't pre-exist its templates failed every quote_sent / invoice_sent /
storno_issued / invoice_reminder_* send with "Email template '<key>'
not found". The queue processor retries 3 times then leaves the row
in status='pending', retry_count=3, silently dead with no admin
surface (see project_crm_backlog for the eventual System Health page).

Fix: wire all three seeders into server.js startServer() right before
startEmailQueueProcessor. The new _emailTemplateBoot.js orchestrates
all three and then, for any template_key it just inserted, resets
retry_count on stuck email_queue rows of that email_type so the
queue processor's next tick picks them back up. Recovery is targeted:
unrelated retry-exhausted rows (e.g. SMTP-timeout failures) are not
touched.

Integration test boots a fresh CRM DB, pre-seeds a stuck quote_sent
row plus an unrelated stuck row, runs the boot helper, and asserts:
templates landed, stuck quote_sent row was reset, unrelated row was
left alone.

Already-deployed installs heal automatically on the next backend
restart after this lands.
2026-05-27 15:18:29 +02:00
Luca 09c5110d2b feat(crm): route billing docs to billing_email when set
Wires customer_accounts.billing_email into the invoice, Storno, and
payment-reminder send paths. Previously the column existed on the
schema and the customer-detail page rendered an input for it, but no
send path read it — every outbound email landed on customer_accounts.email
regardless. That mismatch is the failure mode flagged in
feedback_data_driven_completeness: a UI field that promises behavior
the backend silently doesn't deliver.

Routing matrix:
  - invoice / Storno / payment reminder
      To: billing_email (fallback email when unset)
      CC: email (when billing_email took the To slot) + per-doc cc_pdf_email
  - quote / contract / event reminder / gallery share
      To: email (unchanged — decision-maker address)
  - payment-check / paid-notification
      To: admin contact (unchanged — internal flow)

A new resolveBillingRecipients helper centralises the rules:
prefer billing_email, dedupe addresses case-insensitively, keep
per-doc cc_pdf_email as a supplemental CC. Lives in its own file
(_billingRecipients.js) to match the _renderContext.js convention.
2026-05-27 14:04:15 +02:00
Luca 3d37324080 feat(crm): allow negative line items for manual discount/Rabatt rows
Drops the isInt({ min: 0 }) constraint on lineItems.*.unitPriceMinor
in both the adminInvoices and adminQuotes POST/PUT validators so
admins can add Treuerabatt / Frühbucherrabatt rows as standalone
negative-priced lines (matches standard DE/CH invoice practice).

A service-layer guard rejects saves whose computed total goes below
zero (INVOICE_TOTAL_NEGATIVE / QUOTE_TOTAL_NEGATIVE, both 400) so a
mis-typed discount can't accidentally mint a credit-balance invoice
that would masquerade as a regular row in dashboards. Credit notes
still belong in the Storno path (createStorno), which is unchanged.

Quote-side integration coverage is omitted for now — createQuote's
cold-require path takes ~30s under the test harness; the invoice
test exercises the same validator + guard shape.
2026-05-26 23:54:50 +02:00
Luca 6d302e7998 feat(crm): add event_reminder_* templates to dev email tester
The pre-event reminder feature shipped with 5 seeded templates
(event_reminder_default + wedding/birthday/corporate/other) but the
CRM → Development "Send any CRM email to me" picker only listed the
quote/invoice/contract templates. Maintainer can now eyeball each
reminder category's body without staging a real event.

Backend:
- Extend TEMPLATES_KEYS in adminDev.js with all 5 reminder keys.
- Add event_date (today+2d), days_before (2), business_name (from
  business_profile.legal_name) to the common payload so the
  {{tokens}} in the reminder bodies resolve.

Frontend:
- Extend CrmEmailTemplateKey union.
- Add TEMPLATE_LABEL_KEYS entries.
- EN+DE i18n labels under crmDev.templates.label.event_reminder_*.

No PDF attachment — reminders are body-only emails (matches the
real flow).
2026-05-26 20:44:11 +02:00
Luca b064aab8ef feat(crm): unlock reminderEmails feature flag in Features tab
The full reminder-email implementation (eventReminderService,
eventReminderTemplates self-heal, ReminderTemplatesPage,
EventReminderOverrideCard) shipped in the CRM bundle but the
FeaturesTab card kept lockedReason=NOT_YET_AVAILABLE — so the
working feature was invisible.

Flip the card to the same shape as customerPortal: status="beta",
real setFlag handler, no disabled/lockedReason. The sub-tab in
Settings → Reminder templates already self-mounts when the flag
is on, and the per-event override card already self-renders on
the event detail page.

Description copy + EN/DE i18n updated to describe what the feature
actually does (per-category pre-event nudge) instead of the old
"coming soon" placeholder.
2026-05-26 20:31:14 +02:00
Luca 3240137f1e test(crm): integration harness + schema-shape regression net
Adds two pieces:

- __tests__/integration/helpers/crmDb.js — boots a temp-SQLite test
  DB by invoking every migrations/core/*.up() directly. Bypasses
  knex's Migrator because its exclusive write lock deadlocks
  001_init's nested initializeDatabase() call. ~1 second cold start.

- __tests__/integration/crmSchema.test.js — 36 assertions on the
  table + column layout after the consolidated CRM migration runs.
  Pins:
    - every CRM table present (quotes, contracts, invoices + the
      eight supporting tables)
    - deal_uuid columns on all three lineage tables (the column
      DocumentLineageCard joins on — drop it anywhere and the card
      silently returns partial data)
    - back-pointer FKs (converted_contract_id, source_contract_id,
      source_quote_id) — the exact columns that triggered the
      Postgres FK-ordering bug fixed earlier in this PR
    - Storno discriminator (kind, cancels_invoice_id, replaces_
      invoice_id) per feedback_storno_filter_everywhere
    - event time columns from migration 137

A full quote→contract→invoice lineage walk is deferred — quote
service's nextQuoteNumber() opens an inner transaction from inside
the createQuote outer transaction, which deadlocks SQLite's default
1-connection pool. Postgres dev DBs never see it. Either fix the
service to thread trx through, or run lineage tests against a real
Postgres in CI (mirror schema-drift.yml). Filed as separate work.
2026-05-26 19:50:57 +02:00
Luca 482043f786 ci: run backend Jest + frontend Vitest on every PR
The suites already existed (538 backend tests, 40 frontend tests, with
solid CRM coverage on quoteService/contractService/invoiceService/
customerHoursService/eventService.calendar) but no CI workflow invoked
them. Wire both into a single Tests workflow that triggers on any push
or PR to main/beta.

Six backend suites are excluded — they fail on upstream/beta too
(supertest fixture + knex mock chain issues unrelated to CRM). The
explicit ignore pattern keeps the workflow green on day 1; each
excluded suite is listed inline as test-infra debt to fix individually.

Backend job pins SKIP_S3_TESTS=true (the same default the test setup
file applies) so the backup-service integration doesn't try a real S3
round-trip when no MinIO is provisioned.
2026-05-26 19:08:48 +02:00
Luca b9cadf002c test(crm): update mocks for new createInvitation + OG date-format behavior
Two upstream tests regressed because the CRM PR added expected behavior
they didn't anticipate:

- galleryOgService.shareImage.test.js: formatEventDate is now async and
  routes through utils/dateFormatter so the OG card respects the admin's
  general_date_format setting (per feedback_respect_general_format_settings).
  That adds a third db('app_settings') call on every buildOgMetadata path.
  Mock the formatter module directly — the format itself is irrelevant
  to the cover-vs-logo contract this file pins.

- customerAccountsService.test.js: createInvitation now allows a duplicate
  email when the existing row is PASSIVE (password_hash IS NULL) — that's
  the "promote passive customer to portal" path. The active-customer
  rejection mock now has to set password_hash so the guard fires.

Both are test-only changes; no service code touched.
2026-05-26 19:07:31 +02:00
Luca 88a6b34c5e fix(migrations): defer cross-table FKs in 107_crm_consolidated
quotes.converted_contract_id and invoices.source_contract_id were
declared with inline FKs to contracts(id), but contracts is created
later in the same migration. SQLite accepted the forward reference;
Postgres rejected it ("relation \"contracts\" does not exist"), which
broke the Schema drift (#530) workflow and any fresh Postgres install.

Same pattern as events.hero_photo_id → photos.id in db.js: declare the
column without a constraint, then add the FK in a separate alterTable
after both sides exist. Wrapped in try/catch so re-runs against a DB
that already has the constraint are a no-op.

Verified locally against the #530 recovery scenario (initializeDatabase
then migrate:safe) and the fresh-install path: both converge cleanly,
both FKs land on the expected tables.
2026-05-26 18:49:38 +02:00
Luca e7db0bb866 docs(crm): legal/financial disclaimers — examples only
Adds a top-level disclaimer section to README + a dedicated
docs/crm-disclaimers.md spelling out two areas where picpeak ships
defaults the operator MUST review before going live:

1. Contract blocks (image rights, NDA, model release, cancellation,
   jurisdiction, …) — written by the maintainer, NOT by a lawyer.
   Every operator must have their lawyer review and adapt them
   before sending any contract to a customer.

2. QR-bills and SEPA EPC payloads — rendered from the data the
   operator typed. Picpeak is open source; we recommend scanning a
   test invoice with the operator's bank app to verify the QR
   actually works.

Matches the on-screen amber disclaimers already shown on the
Contract Block Library page and the Business Profile payment-block
editor.
2026-05-26 18:20:55 +02:00
Luca 409d414035 feat(crm): i18n EN + DE for CRM, machine fr/nl/pt/ru fallbacks
~940 new keys per primary locale covering every CRM surface:
quote / invoice / contract editor + list + detail + public response
pages, calendar, hours, tax report, deals lineage, reminder emails,
feature toggles, settings tabs, error toasts.

en.json + de.json are hand-translated by the maintainer and are
authoritative. fr / nl / pt / ru received the same key set but
machine-derived strings — flagged for native review in the PR
description per project policy (see memory feedback_translation_flagging).

3-way merge note: 1 conflict (fr.json) hand-resolved to keep
upstream's improved phrasing for previewLayout / livePreview /
heroPlaceholderText alongside feat/crm's pdfTypography keys.
2026-05-26 18:19:55 +02:00
Luca a7e16e7bf6 feat(crm): frontend code — pages + services + components
Brings in the full frontend CRM stack: admin authoring pages,
customer-portal surfaces, public response flows, typed services,
and the supporting component library. i18n locale JSON is the next
commit (kept separate so reviewers can read it as data).

Pages
  - Quotes: list / editor / detail / public accept-decline
  - Invoices: list / editor / detail / public payment-check
  - Contracts: list / editor / detail / block library / public sign
  - Calendar (FullCalendar — admin-only v1)
  - Tax report (period picker + CSV/PDF export)
  - Hours (logged time entries, per-customer)
  - Deals lineage (DocumentLineageCard surfaces)
  - CRM Development (admin dev tools, gated by crmDevelopment flag)
  - Customer-portal pages for quotes / invoices / contracts
  - Settings reorg: CRM-Settings group + dedicated tabs for Business
    Profile, CRM behaviour, Contracts block library, Reminder emails
  - BrandingPage typography (PDF font picker)
  - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions
    (event-time fields, hours toggle, per-event reminder override)

Services (typed)
  - quotes.service, bills.service, contracts.service
  - customerAdmin.service, deals.service, calendar.service,
    taxReport.service, contracts-blocks.service
  - businessProfile.service (timezone, font picker, bank accounts)
  - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension

Components (admin)
  - CustomerPicker (shared across quote/invoice/contract editors)
  - LineItemsTable (hierarchy + details_text, memoised pricing)
  - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger)
  - DocumentLineageCard (deal_uuid grouped view)
  - EditInstallmentPlanModal (atomic post-spawn plan reshape)
  - EventReminderOverrideCard, EmailTemplateEditor (tiptap),
    PdfFontPicker, IntegrityCheckCard
  - Feature-flag context + RequireFeature wrapper + AdminSidebar
    featureFlagsAny derivation + UI-hiding sweep

Build infra
  - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded)
  - frontend/package.json: tiptap, fullcalendar, signature_pad,
    react-international-phone, et al.
  - tailwind + prose styles updated for editor surfaces

3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved
to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern
over feat/crm's hardcoded option list; feat/crm's DecimalInput
import preserved alongside.
2026-05-26 18:19:32 +02:00
Luca d543949188 feat(crm): backend code — services + routes + utilities + tests
Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).

Services (CRM)
  - quoteService — full lifecycle (draft → sent → accepted → converted
    to event/invoice), Skonto + Storno + reissue paths
  - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
    monthly-billing accumulator, payment-check tokens, dunning ladder
  - contractService — block-composable contract editor, in-browser
    signature flow, wet-PDF upload path, integrity check, audit trail
  - customerHoursService — per-entry locking, billing integration
  - dealsService — cross-document lineage (deal_uuid)
  - taxReportService — quarterly aggregates + CSV/PDF export
  - eventReminderService — pre-event customer reminder cron pass
  - _renderContext — shared issuer/recipient blocks across PDF types
  - pdfService extensions — custom-font registration, font picker

Routes (admin + public)
  - adminQuotes, adminInvoices, adminContracts, adminCalendar,
    adminDeals, adminTaxReport, adminDev, adminBusinessProfile
  - publicQuotes (accept/decline), publicContracts (sign),
    publicPaymentCheck
  - Extensions on adminEvents, adminCustomers, adminSettings,
    adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
    adminCategories, adminUsers, adminArchives, adminDashboard
  - server.js wires the new mounts (kept upstream's noStoreCache on
    customer routes per 3-way merge)

Utilities
  - schemaCache (cached hasColumn lookups across services)
  - documentSequences (atomic gap-free numbering — §14 UStG)
  - safePath (path-containment guards at fs stream boundaries)
  - clientIp (sanctioned XFF reader for audit logs)
  - publicTokenGuards (pre-multer token validation + attempt counters)
  - numericHelpers (ensureInt / ensureNumber consolidation)
  - dateFormatter (formatShortDate + dateInputLang)
  - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile

Infrastructure
  - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
    Montserrat / Noto-Sans / Playfair-Display / Poppins)
  - Backend package.json + lock updates (pdfkit, signature_pad,
    qrcode, et al.)
  - Sample storage layout under storage/business-docs/quote/

Tests
  - 14 new test files covering quote/invoice/contract lifecycle,
    installment plan reshape, line-item hierarchy, customer hours,
    payment check, tax report PDF, IBAN parsing, filename sanitiser
2026-05-26 18:18:51 +02:00
Luca 60abe8c76d chore(migrations): consolidate CRM migrations 102-143 + extract email-template seeds to self-heal services
Replaces what would have been 42 individual in-flight migrations
(102→143 on feat/crm) with one consolidated migration that creates
every CRM table in its final shape — no ALTER chains. Coexists with
upstream's pre-existing 102-106 by filename suffix; the runner sorts
within same-number groups.

Tables consolidated:
  - business_profile + business_bank_accounts (issuer block, fonts,
    PDF layout knobs, tax_id, timezone)
  - payment_term_templates (legacy) + payment_net_days_templates +
    payment_timing_templates (124's split)
  - quotes / quote_line_items / quote_line_item_presets / quote_action_tokens
  - invoices / invoice_line_items / invoice_payment_log /
    invoice_payment_check_tokens
  - contracts / contract_blocks (13 system blocks seeded) /
    contract_block_inclusions / contract_action_tokens
  - event_payment_plans, customer_hour_entries, document_sequences

ALTER on upstream tables (hasColumn-guarded):
  - events: quote_id, calendar columns (event_time_*, is_full_day),
    event_reminder_*
  - customer_accounts: billing_cadence/cycle_day, country_name,
    feature_hours_logging, hourly_rate_minor

Seeds:
  - RBAC perms (quotes/bills/contracts .view/.manage) + customers.create
    split into edit + events (mig 134)
  - Feature flags (quotes, bills, contracts, hoursLogging, taxReport,
    calendar, calendarBooking, reminderEmails, crmDevelopment, messaging
    — all default OFF)
  - 30+ CRM app_settings rows (skonto/QR/reminder windows, payment
    defaults, installment defaults, ToS, event reminder defaults)
  - 4 + 5 + 4 payment-term system rows across the legacy + split tables

Email-template content moves out of the schema diff into three
runtime self-heal service files that idempotently create missing
rows + backfill empty translations on first access (per the maintainer's
"never ship compensation migrations" rule):

  - backend/src/services/crmEmailTemplates.js (NEW) — quote_sent,
    quote_accepted_*, quote_declined_admin, invoice_sent,
    invoice_reminder_first/second, invoice_paid_receipt,
    invoice_cancelled, invoice_payment_check,
    invoice_paid_admin_notification, storno_issued
  - backend/src/services/contractEmailTemplates.js — contract_sent,
    contract_fully_signed, contract_signed_admin_notification
  - backend/src/services/eventReminderTemplates.js — event_reminder_default
    + per-event-type variants

Smoke-tested on fresh sqlite DB: 84 migrations apply cleanly,
all CRM tables present, seeds populated.
2026-05-26 18:18:15 +02:00
689 changed files with 115256 additions and 13582 deletions
+20 -8
View File
@@ -4,8 +4,11 @@
# Environment
NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker: the secrets-init service writes it to a private volume and reuses it
# across restarts). Set it explicitly only to pin your own value.
# Generate one with: openssl rand -base64 64
#JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
@@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
DB_PASSWORD=your_secure_postgres_password_here
#DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod
# Redis Configuration
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
# IMPORTANT: Same warning applies - avoid $ or escape as $$
REDIS_PASSWORD=your_secure_redis_password_here
#REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASSWORD=your_secure_admin_password_here
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
#ADMIN_EMAIL=admin@yourdomain.com
#ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# For Gmail: use app-specific password
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
buy_me_a_coffee: theluap
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- name: 💬 Discussions
url: https://github.com/the-luap/picpeak/discussions
url: https://github.com/PicPeak/picpeak/discussions
about: Ask questions and discuss with the community
- name: 🔒 Security Issues
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
about: Please review our security policy for reporting vulnerabilities
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
+9 -9
View File
@@ -42,16 +42,16 @@ Once published, images can be pulled using:
```bash
# Pull backend image
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/picpeak/picpeak/backend:latest
# Pull frontend image
docker pull ghcr.io/the-luap/picpeak/frontend:latest
docker pull ghcr.io/picpeak/picpeak/frontend:latest
# Pull specific version
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
```
### Using in Docker Compose
@@ -61,14 +61,14 @@ version: '3.8'
services:
backend:
image: ghcr.io/the-luap/picpeak/backend:latest
image: ghcr.io/picpeak/picpeak/backend:latest
environment:
- NODE_ENV=production
ports:
- "3001:3000"
frontend:
image: ghcr.io/the-luap/picpeak/frontend:latest
image: ghcr.io/picpeak/picpeak/frontend:latest
ports:
- "80:80"
```
@@ -86,7 +86,7 @@ spec:
spec:
containers:
- name: backend
image: ghcr.io/the-luap/picpeak/backend:latest
image: ghcr.io/picpeak/picpeak/backend:latest
imagePullPolicy: Always
```
@@ -149,8 +149,8 @@ If images aren't visible after successful push:
### View Packages
Your Docker images are available at:
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
- Backend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Ffrontend`
### Delete Old Versions
+70
View File
@@ -0,0 +1,70 @@
name: Bypass size gate
# Caps how large a PR a "review-bypass" collaborator (e.g. @Luca-Timo) can
# self-merge without a maintainer review. The branch-protection bypass list
# alone is binary — once a user is on it they can merge anything without
# review. This workflow reports a REQUIRED status check that fails when a
# bypass user's PR exceeds the configured size threshold, which blocks the
# merge even with bypass enabled. Other contributors are unaffected (the
# check reports success for them so the required-check gate doesn't trip).
#
# To tune: edit LINE_LIMIT or BYPASS_USERS below.
#
# Trigger note: uses `pull_request_target` so the workflow has the elevated
# permissions of the base repo's GITHUB_TOKEN (read PR metadata, write
# checks). The script never executes code FROM the PR — it only reads
# metadata via the API — so this is safe against fork-PR attacks.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: read
checks: write
jobs:
size-gate:
runs-on: ubuntu-latest
steps:
- name: Compute PR size and report check status
uses: actions/github-script@v7
with:
script: |
// Tune these two constants if the policy shifts.
const LINE_LIMIT = 300;
const BYPASS_USERS = ['Luca-Timo'];
const pr = context.payload.pull_request;
const author = pr.user.login;
const linesChanged = pr.additions + pr.deletions;
const filesChanged = pr.changed_files;
let conclusion, title, summary;
if (!BYPASS_USERS.includes(author)) {
// Not a bypass user — this gate doesn't apply to them. They
// go through normal review. Report success so the required
// check doesn't block their merge.
conclusion = 'success';
title = 'Not applicable';
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
} else if (linesChanged <= LINE_LIMIT) {
conclusion = 'success';
title = `OK — within bypass limit (${linesChanged} lines)`;
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
} else {
conclusion = 'failure';
title = `Too large for bypass (${linesChanged} lines)`;
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'bypass-size-gate',
head_sha: pr.head.sha,
status: 'completed',
conclusion,
output: { title, summary }
});
+44 -13
View File
@@ -1,7 +1,8 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
# - Push to main/stable branches (main → ':main' rolling tag for active-dev
# builds; stable → ':stable' + ':latest' for the curated channel)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
@@ -20,10 +21,10 @@ name: Build and Push Docker Images
on:
push:
branches: [ main, beta ]
branches: [ main, stable ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main, beta ]
branches: [ main, stable ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
@@ -37,6 +38,16 @@ on:
- 'true'
- 'false'
# Once release-please authors releases with a PAT (#719), a new version fires
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
# suppress them). They build the same immutable version, so collapse them into a
# single run by grouping on the ref. Branch and PR builds use different refs and
# still run independently; a superseding push cancels an in-flight run for the
# same ref (only the newest build per ref is kept).
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
@@ -134,7 +145,10 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
@@ -242,7 +256,9 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -267,9 +283,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
@@ -360,7 +381,10 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
@@ -449,7 +473,9 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -474,9 +500,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
+11 -17
View File
@@ -16,24 +16,17 @@ name: Fresh-install smoke
# don't pay the build cost.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
# required check "missing" forever and block the merge. Better to
# pay the boot cost on every PR than maintain a per-path allowlist
# that drifts as the install surface evolves. (Branches also updated
# post-#669 rename: beta → main, old main → stable.)
push:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
branches: [main, stable]
pull_request:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
branches: [main, stable]
workflow_dispatch:
permissions:
@@ -61,7 +54,8 @@ jobs:
load: true
tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-smoke
cache-to: type=gha,mode=max,scope=install-smoke
# ignore-error: a flaky GHA cache write must not fail the build.
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
- name: Create Docker network
run: docker network create picpeak-smoke
+36
View File
@@ -0,0 +1,36 @@
name: PR Title Lint
# Release Please derives version bumps and the changelog from Conventional
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
# without a version bump or a changelog entry. This check fails a PR whose
# title is not a valid Conventional Commit so the release stays automated.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
lint-pr-title:
runs-on: ubuntu-latest
steps:
- name: Validate PR title is a Conventional Commit
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
revert
docs
style
chore
refactor
test
build
ci
+54 -3
View File
@@ -2,7 +2,7 @@ name: Release Please (Beta)
on:
push:
branches: [beta]
branches: [main]
permissions:
contents: write
@@ -20,10 +20,48 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
# A dedicated token (fine-grained PAT) makes the release PR run CI
# automatically (no "workflows awaiting approval") and lets it be
# merged without a manual review. Falls back to GITHUB_TOKEN so the
# workflow still works before the secret is added (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
target-branch: main
# Auto-approve + enable auto-merge on the open release PR so betas publish
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
# a valid review (requires the org's "Allow GitHub Actions to approve pull
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
# set: without it the PR is bot-authored and can't be self-approved, so we
# skip and leave today's manual flow. Best-effort — never blocks the run.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# This job has no checkout, so gh can't infer the repo from a git
# remote — set it explicitly (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--main --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
# than the PR author (the PAT) — so it counts as a valid review.
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
# Enable auto-merge as the PAT so the eventual merge commit is
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
# push is suppressed by recursion prevention and the follow-up run that
# cuts the tag/release never fires (#719).
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -35,3 +73,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+45 -2
View File
@@ -2,7 +2,7 @@ name: Release Please
on:
push:
branches: [main]
branches: [stable]
permissions:
contents: write
@@ -20,9 +20,39 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Dedicated token so the release PR runs CI + can auto-merge without a
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -34,3 +64,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+9 -12
View File
@@ -30,20 +30,17 @@ name: Schema drift (#530)
# the same shape is caught before merge.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
# trigger that skipped on unrelated PRs would leave the required
# check "missing" forever, blocking every PR that doesn't touch
# migrations. The ~75-second cost on every PR buys an unconditional
# safety net. (Branches also updated post-#669 rename: beta → main,
# old main → stable.)
push:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
branches: [main, stable]
pull_request:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
branches: [main, stable]
workflow_dispatch:
permissions:
+89
View File
@@ -0,0 +1,89 @@
name: Tests
# Runs the backend Jest suite and the frontend Vitest suite on every PR.
# Both suites already exist and cover the CRM service layer (quoteService,
# contractService, invoiceService.*, customerHoursService, eventService.
# calendar) plus the photo / settings / OG / auth surface — wiring them
# into CI makes regressions visible at PR time instead of post-merge.
#
# Six backend suites are excluded via --testPathIgnorePatterns. They
# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM
# regressions). Excluding them here keeps CI green from day 1; revisit
# each individually as its own fix.
#
# Triggers on any change that could affect either suite. The backend
# job intentionally omits frontend paths and vice versa so unrelated
# PRs don't pay both build costs.
on:
push:
branches: [main, beta, stable]
pull_request:
branches: [main, beta, stable]
workflow_dispatch:
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: ./backend
run: npm ci
- name: Run Jest suite
working-directory: ./backend
env:
# backupService tests would otherwise try a real S3 round-trip.
# The S3 path itself is covered separately by the integration
# suite when MinIO is provisioned.
SKIP_S3_TESTS: 'true'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
# adminSettings.logo — supertest fixture
# integration/adminPhotos.reference — supertest fixture
# integration/webhookDelivery — supertest fixture
# services/backupService.enhanced — knex mock chain
# routes/__tests__/adminAuth — supertest fixture
# (adminNotifications was excluded; #597 fix re-enables it.)
npx jest \
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
--ci
frontend:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
working-directory: ./frontend
run: npm ci
- name: Run Vitest suite
working-directory: ./frontend
run: npm test -- --run
+98
View File
@@ -0,0 +1,98 @@
# What's New highlights — GitHub Models release step (reusable)
#
# Called by the release-please workflows AFTER a release is created
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
# as a job in the SAME workflow run rather than on its own `release: published`
# trigger, because release-please creates the release with the default
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
# events — a standalone `release:` workflow would simply never fire.
#
# What it does: condenses the new release's "### Features" into <=8 short
# bullets via GitHub Models (free tier, `models: read`) and injects a
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
# Features list for releases without it — so this is purely a quality upgrade,
# never a hard dependency. Failure is isolated by `continue-on-error` + the
# deterministic fallback below, so it can never break a release.
#
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
# step fails soft (continue-on-error) and the deterministic fallback produces
# the bullets instead — the feature works either way, Models just polishes them.
#
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
# real release notes; app parseWhatsNew() reads the block back).
name: What's New highlights
on:
workflow_call:
inputs:
tag:
description: Release tag to annotate (e.g. v2.3.0)
required: true
type: string
jobs:
highlights:
runs-on: ubuntu-latest
permissions:
contents: write # to edit the release body
models: read # GitHub Models (free tier)
# GH_REPO at job scope so every `gh` call targets the right repo without
# needing an actions/checkout step. Without this, `gh` falls back to
# parsing `.git/config` in the runner's empty workspace and dies with
# "fatal: not a git repository" — which hard-fails the whole job before
# any continue-on-error can save it.
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Extract Features from the published release
id: feat
# Belt-and-braces: the job-level comment says "never let highlights
# break a release", but the original wiring only marked the AI +
# inject steps as continue-on-error. A hiccup here (rate limit,
# transient API error) would still hard-fail the job. Match the
# design intent and fail soft.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
run: |
BODY=$(gh release view "$TAG" --json body -q .body)
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
- name: Summarize with GitHub Models
if: ${{ steps.feat.outputs.features != '' }}
id: ai
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
uses: actions/ai-inference@v1
with:
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
system-prompt: >
You write release highlights for the admins of a self-hosted
photo-gallery + CRM app. Given raw changelog "Features" lines, output
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
no jargon, no issue numbers. One bullet per distinct user-visible
feature. Output ONLY "- " bullets, nothing else.
prompt: ${{ steps.feat.outputs.features }}
- name: Inject the What's New block
if: ${{ steps.feat.outputs.features != '' }}
continue-on-error: true # never let highlights break a release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
AI: ${{ steps.ai.outputs.response }}
FEATURES: ${{ steps.feat.outputs.features }}
run: |
BULLETS="$AI"
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
if [ -z "$BULLETS" ]; then
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
fi
BODY=$(gh release view "$TAG" --json body -q .body)
# Idempotent: strip any prior block before re-injecting.
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
+3
View File
@@ -129,3 +129,6 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.55.0-beta.0"
".": "3.83.0-beta.0"
}
+1 -1
View File
@@ -1 +1 @@
{".":"3.44.0"}
{".":"3.45.2"}
+375
View File
@@ -5,6 +5,381 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.45.2](https://github.com/PicPeak/picpeak/compare/v3.45.1...v3.45.2) (2026-07-17)
### Bug Fixes
* **security:** remove unguarded legacy /api/events router (GHSA-4j34-x562-5vfq) ([9ee3ff4](https://github.com/PicPeak/picpeak/commit/9ee3ff45d0b9754ddaf89f63a9e0546d3618fdb8))
* **security:** remove unguarded legacy /api/events router on stable (GHSA-4j34-x562-5vfq) ([e37d1fa](https://github.com/PicPeak/picpeak/commit/e37d1fac586988d59846f90a5195db129b330a5b))
## [3.45.1](https://github.com/PicPeak/picpeak/compare/v3.45.0...v3.45.1) (2026-07-16)
### Bug Fixes
* **security:** close 4 open security advisories on stable (backup takeover, share-login bypass, ZIP slip, chunked-upload traversal) ([b416bae](https://github.com/PicPeak/picpeak/commit/b416baec5c4d40e8558161a88fb842bbee84d470))
* **security:** harden .picpeak restore operator-preservation (GHSA-qxfx follow-up) ([b00a161](https://github.com/PicPeak/picpeak/commit/b00a16159eea4815c70dba8a6ebb13be58ef9492))
* **security:** preserve current admin on .picpeak restore (GHSA-qxfx-4493-4v8f) ([1cf82d8](https://github.com/PicPeak/picpeak/commit/1cf82d81a7935ca106b36521cbf02f63cd2e14b6))
* **security:** reject ZIP-slip entries in archive/backup restore (GHSA-jfhw-fj23-fx6x) ([cde0b46](https://github.com/PicPeak/picpeak/commit/cde0b465a90169348475c0415d0d96df1cd5cc44))
* **security:** sanitize chunked-upload filename (GHSA-pc72-jf53-w28j) ([dcfcb67](https://github.com/PicPeak/picpeak/commit/dcfcb67f9b2b6294b1ae033f7d0b707b0749a28a))
* **security:** share-login must not bypass gallery password (GHSA-9hmx-68vc-qpqw) ([28f69e4](https://github.com/PicPeak/picpeak/commit/28f69e4bf3b1d99748d53eb2671617ab06e4fedb))
## [3.45.0](https://github.com/PicPeak/picpeak/compare/v3.44.0...v3.45.0) (2026-07-09)
### Features
* **accounting:** Accounting nav section + relocate Tax report out of CRM ([30c0007](https://github.com/PicPeak/picpeak/commit/30c0007f40594e679f5d997219985107de784cf4))
* **accounting:** Accounting settings tab (km / per-diem rate, require-proof) ([2b7495e](https://github.com/PicPeak/picpeak/commit/2b7495e4dc46c6033f581a0ce2ffde83b9ef89b4))
* **accounting:** add a Banana "Income & Expense" (cash-book) export format ([445d6d7](https://github.com/PicPeak/picpeak/commit/445d6d7b6d6b0692d5b7c0a3dd0ca8b71dfad6ef))
* **accounting:** backend rework - incoming invoices vs internal expenses (stage 2) ([5e78fb6](https://github.com/PicPeak/picpeak/commit/5e78fb6475513cbda38257b406493eb649a5e33c))
* **accounting:** bill editor VAT dropdown + GET returns vat_code snapshot ([2479d87](https://github.com/PicPeak/picpeak/commit/2479d87afc5b094a5323bca0b67e6404ce05a4e2))
* **accounting:** clearer tax-export window + gate journal export on accounting flag ([3edd832](https://github.com/PicPeak/picpeak/commit/3edd8321035c48d6b3e8b157d4b94075657fc7a0))
* **accounting:** consolidate VAT/financial config into Settings → Accounting ([dc7b87b](https://github.com/PicPeak/picpeak/commit/dc7b87bb874e22ac6902fd2d48531ecdb6108c88))
* **accounting:** data-driven revenue-rate VAT map (multi-country) ([873be91](https://github.com/PicPeak/picpeak/commit/873be910a5e88a6d942f116c2bcfecfef9161024))
* **accounting:** event booking via dropdown (Company or an event) ([81af445](https://github.com/PicPeak/picpeak/commit/81af4453e796285df05446cfd333e53b6b1cc19e))
* **accounting:** expense invoiced/paid lifecycle + edit-until-invoiced; decouple tax report from bills flag ([2e8e4a0](https://github.com/PicPeak/picpeak/commit/2e8e4a0f86d5b7a4dc0fa712bcb4cbab4fbe9861))
* **accounting:** expenses ledger + supplier-payment toggle ([0c35ac4](https://github.com/PicPeak/picpeak/commit/0c35ac43e616e4b49da8089c95239ac06ec402c5))
* **accounting:** explain dispositions inline, drop markup from pass-through ([9a023c0](https://github.com/PicPeak/picpeak/commit/9a023c019750ebcd8d21e005aaf9a77a32cb34a3))
* **accounting:** frontend rework - separate Incoming invoices vs Expenses (stage 2) ([f305541](https://github.com/PicPeak/picpeak/commit/f305541f903c02329309a5d2513eded9792c58c5))
* **accounting:** inbound supplier-invoice capture + expense re-bill (backend) ([c305492](https://github.com/PicPeak/picpeak/commit/c3054928453a23c599894b669b6ac3c623fa4183))
* **accounting:** incoming-invoice triage refinements (paid badge, click-to-categorize, reference, categorize+pay) ([72b784c](https://github.com/PicPeak/picpeak/commit/72b784c9d751d2c98014cf35ce3035c60664056a))
* **accounting:** incoming-invoice workflow v2 + VAT/financial settings consolidation ([b527915](https://github.com/PicPeak/picpeak/commit/b5279155ea4c545e13bab8bde46a39cfccf107fe))
* **accounting:** incoming-invoices inbox with camera capture + triage/re-bill ([2b5efeb](https://github.com/PicPeak/picpeak/commit/2b5efebaff0ed05b99b84802811ee662911e9468))
* **accounting:** invoices force-enable the Accounting master ([51837c3](https://github.com/PicPeak/picpeak/commit/51837c3a88f711b164fafe2c7677e1a91c7542f9))
* **accounting:** Layer A backend — chart of accounts, VAT codes, Treuhänder export ([03cc250](https://github.com/PicPeak/picpeak/commit/03cc250b47518573d2cd4fa45da331a3e47ea3fd))
* **accounting:** Layer A frontend — chart of accounts CRUD + Treuhänder export UI ([7e0098e](https://github.com/PicPeak/picpeak/commit/7e0098edcd42dc9d6b91c39f60627122d8394bff))
* **accounting:** manual "add expense" (no document) on the ledger ([703f727](https://github.com/PicPeak/picpeak/commit/703f72742ddc9028a3214e108563320679ec4ade))
* **accounting:** move Chart of accounts into Settings → Accounting ([97795f6](https://github.com/PicPeak/picpeak/commit/97795f6d1ed25d23396a76b63c225b110ddc315e))
* **accounting:** move Treuhänder export onto the Tax page ([b1f73c1](https://github.com/PicPeak/picpeak/commit/b1f73c1df9408ddae821760eb8ed57c726d2e056))
* **accounting:** PDF/image preview in triage, opened at the QR-bill (no OCR) ([502fbad](https://github.com/PicPeak/picpeak/commit/502fbad5a8cb11e075cb098ad05fd94a78aae396))
* **accounting:** rasterise inbound PDFs server-side (never serve raw to browser) ([e111522](https://github.com/PicPeak/picpeak/commit/e111522415c21ca8ef47f0d8be48c95bd8ab8c69))
* **accounting:** re-categorize incoming invoices, note field, pending re-bill pool ([36a8e42](https://github.com/PicPeak/picpeak/commit/36a8e42f90f15a1ba96d9c4f004fa542d33e4937))
* **accounting:** re-viewable incoming invoices + expense invoiced/paid lifecycle UI ([727bdab](https://github.com/PicPeak/picpeak/commit/727bdab6e087e1a3611295417a05f61939204487))
* **accounting:** relocate VAT codes + rate maps into Settings → Accounting ([4ff5b84](https://github.com/PicPeak/picpeak/commit/4ff5b84cb66e40be960c2be7a67334cf0cc98be2))
* **accounting:** scope the tax-report export to income-only or cost-only ([9f3b286](https://github.com/PicPeak/picpeak/commit/9f3b28684ff36b131420cd975df634a29d660323))
* **accounting:** snapshot the chosen VAT code on quote/invoice create + storno ([5b52969](https://github.com/PicPeak/picpeak/commit/5b52969e36a41bbc08a98e0bca1ce0e937d77f56))
* **accounting:** snapshot vat_code on quotes/invoices + export prefers it (foundation) ([0a7dc1c](https://github.com/PicPeak/picpeak/commit/0a7dc1cf5da17a5bef204f6680844c8ab2b44269))
* **accounting:** split Incoming invoices vs Expenses - flags, schema, settings (stage 1) ([c59df52](https://github.com/PicPeak/picpeak/commit/c59df52d407fec86168aa7a2087c1a29c7660ab8))
* **accounting:** supplier-country tax default + configurable default output VAT code ([267b121](https://github.com/PicPeak/picpeak/commit/267b121d66994bc57b10cd0694ab0e4320b163d9))
* **accounting:** tax report VAT-payable honours registration + reclaim ([d7107aa](https://github.com/PicPeak/picpeak/commit/d7107aaf0adf5e03f08085c7691b6530b15ed702))
* **accounting:** tax window shows all costs (incoming invoices + expenses) alongside revenue ([545ef33](https://github.com/PicPeak/picpeak/commit/545ef334f42d3cb09e63ffc77a80dd684ab5a807))
* **accounting:** unify tax report into one signed, typed, sortable ledger ([fd1dd81](https://github.com/PicPeak/picpeak/commit/fd1dd81e8dfc790d960a7e6d888beba895a23461))
* **accounting:** VAT registration + reclaim-country settings in the Accounting tab ([4d87684](https://github.com/PicPeak/picpeak/commit/4d876848823bce2c79e629308c92206c64d9893d))
* **accounting:** VAT registration/reclaim settings + un-gated VAT-codes read ([fbbbb8a](https://github.com/PicPeak/picpeak/commit/fbbbb8ab7335f07ecf48e217632c7011bd7c88cd))
* **accounting:** VAT-code dropdown in the quote editor (+ reusable VatRateSelect) ([6e1924b](https://github.com/PicPeak/picpeak/commit/6e1924bae8b50dbb8460e151c1d9a79553fddb19))
* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([e873f7c](https://github.com/PicPeak/picpeak/commit/e873f7c98ce108b090d70a3b7df2d2929699e997))
* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([6f95796](https://github.com/PicPeak/picpeak/commit/6f95796b7c19829197eaff0d4934ad9b84d0e2f3))
* admin two-factor authentication (TOTP) with recovery codes + CLI reset ([cf07361](https://github.com/PicPeak/picpeak/commit/cf073615effa8a91e19374ad3e9924e6e7322950))
* admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome) ([15be3b8](https://github.com/PicPeak/picpeak/commit/15be3b8d32965eedc08d46ccc525c65a3bf34de6))
* **admin-ui:** TOTP MFA enrollment + two-step login; remove stub 2FA toggle ([96e3c68](https://github.com/PicPeak/picpeak/commit/96e3c68b9d6b35a82abcad664a6da7b19150b4fd))
* **admin/exports:** inline preview modal with copy-to-clipboard ([#631](https://github.com/PicPeak/picpeak/issues/631)) ([fc5c1ae](https://github.com/PicPeak/picpeak/commit/fc5c1ae93f87678fcc16bc84a14a60a59b1a3c7b))
* **admin/exports:** inline preview modal with copy-to-clipboard ([#631](https://github.com/PicPeak/picpeak/issues/631)) ([27b5f7e](https://github.com/PicPeak/picpeak/commit/27b5f7e4b68e43345cd99dd5cc77308dcd7ec98b))
* **admin:** in-app migration banner for the org rename ([0213347](https://github.com/PicPeak/picpeak/commit/02133478bd2684c562d11cc122cf5059832ff76a))
* **admin:** in-app migration banner for the org rename ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([2a4bf3b](https://github.com/PicPeak/picpeak/commit/2a4bf3b868c6733d0b865c8c0e977ba84d6e6453))
* **analytics:** pluggable trackers — Umami + Rybbit + Custom ([#663](https://github.com/PicPeak/picpeak/issues/663) Phase 1) ([83461fe](https://github.com/PicPeak/picpeak/commit/83461fe5d4d44006482167464d92e70546cf7377))
* **analytics:** pluggable trackers — Umami + Rybbit + Custom ([#663](https://github.com/PicPeak/picpeak/issues/663) Phase 1) ([ab50145](https://github.com/PicPeak/picpeak/commit/ab501459a4208c3a30637a31f0f6f31285ae5ef2))
* **auth:** admin TOTP MFA — enrollment, login challenge, recovery, CLI reset ([72e2ef6](https://github.com/PicPeak/picpeak/commit/72e2ef6721b0572ed34455de901aa357eacd8c76))
* **backup:** .picpeak download + upload-restore UI in Backup Manager ([66d61c8](https://github.com/PicPeak/picpeak/commit/66d61c87cad1ac1994d61e81c8c06739aa0cb8c5))
* **backup:** .picpeak import/restore (full override, keeps current account) ([2920d82](https://github.com/PicPeak/picpeak/commit/2920d82186d16d43674703d09370aeecec6a0da0))
* **backup:** .picpeak portable export (engine-neutral logical snapshot) ([38b3aef](https://github.com/PicPeak/picpeak/commit/38b3aef63d0d4f0878eddeb93f16319ef87f2372))
* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a))
* **backup:** upload + restore endpoint for .picpeak ([2b66f6d](https://github.com/PicPeak/picpeak/commit/2b66f6d889f94848202faf02116efa94ade9bd12))
* **branding:** force color mode = standard look; hide overridden theme controls ([4749e22](https://github.com/PicPeak/picpeak/commit/4749e222dc41695a2494a3b740952745bb854d4e))
* **categories:** per-category download permissions ([#640](https://github.com/PicPeak/picpeak/issues/640) part B) ([820f483](https://github.com/PicPeak/picpeak/commit/820f4835f1f5a41cbef6816c387ef9ec3dafd526))
* **common:** generic Promise-based ConfirmDialog primitive ([#640](https://github.com/PicPeak/picpeak/issues/640) part C) ([a3fcb5b](https://github.com/PicPeak/picpeak/commit/a3fcb5bc9e82849ebe1f55620e8aa7e60ccd973f))
* **crm:** 3-reminder dunning + flat/percent Mahngebühr on 2nd & 3rd + AGB notice ([dcdbeb9](https://github.com/PicPeak/picpeak/commit/dcdbeb9cc50a888ff66e65c62597fcc84c2c8f17))
* **crm:** event-type dropdown on quotes; quote→event uses it (no more hardcoded 'wedding') ([f78671f](https://github.com/PicPeak/picpeak/commit/f78671fc6c8d234be4dee87b45aae9d280a3a6f7))
* **crm:** Mahngebühr on a separate Mahnung document; invoice stays immutable ([5ed2fec](https://github.com/PicPeak/picpeak/commit/5ed2fec2fe5cd5c2c8db6ea83c3b791313618e0f))
* **crm:** pre-event reminder falls back to the assigned customer account ([3ccaed0](https://github.com/PicPeak/picpeak/commit/3ccaed06a226cdc4a18399f8daa9b2d420e3684b))
* **crm:** Project Overview phase 1 — projects schema ([efa47d6](https://github.com/PicPeak/picpeak/commit/efa47d697d4c007f27c12f4f33eb62ef64e09e78))
* **crm:** Project Overview phase 2 — project service + routes ([eb26313](https://github.com/PicPeak/picpeak/commit/eb263137b98935754155824de2a03848121304b6))
* **crm:** Project Overview phase 3 — persist sent email HTML ([874c91f](https://github.com/PicPeak/picpeak/commit/874c91f944d8397edaf9f48091bab3bf5cfc30e1))
* **crm:** toggle for VAT on late fees (jurisdiction-dependent) ([eaceb7e](https://github.com/PicPeak/picpeak/commit/eaceb7e71caa6466d8298c0fc84487f0f4af1dca))
* **dashboard:** revenue "year" tile toggles 365 days ↔ calendar YTD ([d1c9e02](https://github.com/PicPeak/picpeak/commit/d1c9e02bcf50b6c08eebc85acdbfba29bfee84ac))
* **email:** add 'Test connection' to incoming mail + tidy IMAP label ([f017649](https://github.com/PicPeak/picpeak/commit/f017649bd5072d6d23a251fedeba473fe0bf57bf))
* **email:** incoming mail (IMAP) intake - backend + standalone flag ([5645c30](https://github.com/PicPeak/picpeak/commit/5645c304ab876f42bf6187d624c256f07e62e482))
* **email:** incoming mail UI - IMAP config block + Received emails tab ([31280e1](https://github.com/PicPeak/picpeak/commit/31280e1f7ab374f3ebd6e2b3efa5313cbde860ab))
* **email:** round-trip test — send via SMTP to the IMAP mailbox and confirm arrival ([04be51a](https://github.com/PicPeak/picpeak/commit/04be51a008d8e9e050557783fb2f5104e140847e))
* **events:** duplicate-gallery action ([#626](https://github.com/PicPeak/picpeak/issues/626)) ([e985d25](https://github.com/PicPeak/picpeak/commit/e985d25207671cbfefcdda9775a96eadf2fe0698))
* **feedback:** export shape toggle — per-action vs per-guest pivot ([#640](https://github.com/PicPeak/picpeak/issues/640) part E) ([fabd67a](https://github.com/PicPeak/picpeak/commit/fabd67aecd6caf308956e5b4cb9df7dd44452142))
* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/PicPeak/picpeak/issues/655)) ([3ac7017](https://github.com/PicPeak/picpeak/commit/3ac70177efc237b8169278208983b0de3629bc72))
* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/PicPeak/picpeak/issues/655)) ([f2814e4](https://github.com/PicPeak/picpeak/commit/f2814e4a4ce3aa9affc232243d615a15a1aae0c0))
* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23))
* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a0f7033](https://github.com/PicPeak/picpeak/commit/a0f7033ffc812f92d56e2eac7bd2f498b95ef83b))
* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([56c2386](https://github.com/PicPeak/picpeak/commit/56c2386c90a090ee1e7f44a51328fc1291fe323f))
* **gallery:** publish notifies assigned customer accounts via the account email ([c657892](https://github.com/PicPeak/picpeak/commit/c657892bc87f44e854827cb8a65f87cc528a2f71))
* **invoices:** optional sub-cent rounding reconciliation ("Rundung" row) ([4670292](https://github.com/PicPeak/picpeak/commit/4670292139bf4c5ad523646f38b98b9d47ab3430))
* **invoices:** surface monthly/manual accumulator drafts in the Bills list ([e457656](https://github.com/PicPeak/picpeak/commit/e457656b9d06bb420c9d0985fe15c30d6c88aed9))
* Live Slideshow ("Diashow") — fullscreen, auto-updating projector view for live events ([4356393](https://github.com/PicPeak/picpeak/commit/4356393b4433dd6b4147388688766b9464294c89))
* **messages:** create/select quote, contract, invoice, gallery from a message ([0dbf863](https://github.com/PicPeak/picpeak/commit/0dbf863f60b919560b766f78b107ebac9612bd9d))
* **messages:** Outlook-style Messages email client (3 phases, flag-gated) ([d0bdcb1](https://github.com/PicPeak/picpeak/commit/d0bdcb1a6afd0f227d468adc35c23bb88410a578))
* **messages:** Phase 1 read-only Messages viewer (email client shell) ([26eeb76](https://github.com/PicPeak/picpeak/commit/26eeb7619703ffe0b29653e409beecd30263bcb0))
* **messages:** Phase 2 — customer (hello@) mailbox + inbound body capture ([ee46cf2](https://github.com/PicPeak/picpeak/commit/ee46cf2125c2721c3cecee8761490652552a2f2a))
* **messages:** Phase 3 — editable-template composer, reply + create actions ([768e847](https://github.com/PicPeak/picpeak/commit/768e84711f3362069d7ce6867e63c727d2009a46))
* **messages:** search bar + Archive/Delete with Archived & Deleted folders ([99d5996](https://github.com/PicPeak/picpeak/commit/99d5996561a2dcff2d431692d5bab5c7286d1f6f))
* **messages:** unified Messages email client (flag-gated, default off) ([a71b9b5](https://github.com/PicPeak/picpeak/commit/a71b9b5ed721df17b61062ae3a2361d448c95cf7))
* **projects:** attach-event control in the cockpit ([dffcf62](https://github.com/PicPeak/picpeak/commit/dffcf6269fe0416d7233d973a4162874337fbb9a))
* **projects:** flag re-rendered emails in the feed ([94f2c01](https://github.com/PicPeak/picpeak/commit/94f2c01590d52962c565eea5d6f47444ff0d5782))
* **projects:** gate Project Overview behind a projects feature flag + cockpit email actions ([1bf0b34](https://github.com/PicPeak/picpeak/commit/1bf0b34ea5e280770e0262660586f345860b0325))
* **projects:** gated project pickers on quote/contract/hours editors ([0175007](https://github.com/PicPeak/picpeak/commit/0175007abc675ecb18a30e05241cac62bee833f8))
* **projects:** link quotes & contracts to a project (precise cockpit rollup) ([6420047](https://github.com/PicPeak/picpeak/commit/6420047e7cda046057d27539b85334874081b51d))
* **projects:** linking a quote/contract cascades the whole deal into the project ([a702f33](https://github.com/PicPeak/picpeak/commit/a702f33004996ca53f77c9177cb50a6504410801))
* **projects:** Project Overview cockpit — link (multiple) quotes/contracts/hours into projects ([58f93ae](https://github.com/PicPeak/picpeak/commit/58f93ae71350cc4a100f15a1a11f478750dace91))
* **projects:** Project Overview cockpit UI + CRM nav entry ([81553aa](https://github.com/PicPeak/picpeak/commit/81553aa0e3476e7fdd2d6fcda7ebfa1f1d7c9b57))
* **projects:** rolled-up project value (newest stage wins per deal, cumulative) ([7ca2437](https://github.com/PicPeak/picpeak/commit/7ca243780a2165b28583a44632468fd05c4ed115))
* setup wizard + argument-driven unattended install ([681619f](https://github.com/PicPeak/picpeak/commit/681619f0a14070309342a9f908a5bbc8a57d47d8))
* **setup:** add "How will you use PicPeak?" feature-selection step ([422dfe1](https://github.com/PicPeak/picpeak/commit/422dfe1cc88ae277dc170e95b4746e507c31b23a))
* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113))
* **setup:** brand first-run screen and split into two-step wizard ([d9b0eb7](https://github.com/PicPeak/picpeak/commit/d9b0eb723295c20e80c0e633ebcdb6e191528607))
* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994))
* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e))
* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777))
* **setup:** step-by-step wizard + argument-driven unattended install ([d35c413](https://github.com/PicPeak/picpeak/commit/d35c413651bc10f177a683a8057ad92c03b1cf00))
* **setup:** validate setup token at step 1 before advancing ([b0912c7](https://github.com/PicPeak/picpeak/commit/b0912c74276ad2489a8d199739d6eee2e8dabf53))
* **slideshow:** add image fit setting (fill vs black bars) ([b5c73e0](https://github.com/PicPeak/picpeak/commit/b5c73e05bd41b262f864e8c700b1d38582b3817f))
* **slideshow:** admin ui for live slideshow ([385b05a](https://github.com/PicPeak/picpeak/commit/385b05adcf6a4acb7939e372328a55df7dae5e08))
* **slideshow:** backend api for live slideshow ([dea5e0f](https://github.com/PicPeak/picpeak/commit/dea5e0f8a6421c056868c2d9bea11e5bf1ee106a))
* **slideshow:** db columns for live slideshow ([1029dd0](https://github.com/PicPeak/picpeak/commit/1029dd05bdb9ca0a97ad86100145221850648651))
* **slideshow:** en/de strings for live slideshow ([cb761ee](https://github.com/PicPeak/picpeak/commit/cb761ee621aa553cf210c4224b6cbbf7bf2ef0cb))
* **slideshow:** gate behind a feature flag + move globals to a Settings tab ([69367b4](https://github.com/PicPeak/picpeak/commit/69367b45be1c13d87e73e72da34a1f41a5849dfe))
* **slideshow:** public fullscreen slideshow viewer ([fd02254](https://github.com/PicPeak/picpeak/commit/fd02254f78bd1860780355ebaa68293d58ce18b3))
* **updates:** "What's New" highlights after update + pre-update teaser ([a1a73bf](https://github.com/PicPeak/picpeak/commit/a1a73bf75ff3fcd0833fdf7922a35ad09f19439b))
* **updates:** "What's New" highlights after update + pre-update teaser ([500cf85](https://github.com/PicPeak/picpeak/commit/500cf8522e556575bd74d4c71d38a83fb2596b5e))
* **whatsapp:** admin-selectable template parameters + reorder ([#647](https://github.com/PicPeak/picpeak/issues/647) follow-up) ([80e8ec5](https://github.com/PicPeak/picpeak/commit/80e8ec5bc71f0653d56f1087521f5207aee0ba8f))
* **whatsapp:** admin-selectable template parameters + reorder ([#647](https://github.com/PicPeak/picpeak/issues/647) follow-up) ([16055cd](https://github.com/PicPeak/picpeak/commit/16055cdc413f0aa560c2b2a792b4e2ae8f5cbfd3))
* **whatsapp:** WhatsApp Business API notification channel ([#640](https://github.com/PicPeak/picpeak/issues/640) part D) ([78c8e9d](https://github.com/PicPeak/picpeak/commit/78c8e9d9f91d56e07e04df4ed90fb05ccdfb69d2))
* **workflows:** 'Clean up layout' auto-arrange button (dagre) ([79607c5](https://github.com/PicPeak/picpeak/commit/79607c597af07ca5b78a689469c3ffefc3699317))
* **workflows:** add workflows feature flag + Features-tab toggle ([ff47861](https://github.com/PicPeak/picpeak/commit/ff478619b580096d4dfb46dd6ad751daead082fb))
* **workflows:** admin CRUD + run-history + approvals-inbox API ([1a0d6de](https://github.com/PicPeak/picpeak/commit/1a0d6de04d3547ca0e34833befea200c2750e859))
* **workflows:** admin review gates before sends + migrate lifecycle/time triggers ([fa7b1ba](https://github.com/PicPeak/picpeak/commit/fa7b1bae951222de506ab8a3c8aee158b471efd1))
* **workflows:** advanced text mode — export/import the flow as JSON ([289568f](https://github.com/PicPeak/picpeak/commit/289568fd52fcf0f33a959f074d215853ae37e48c))
* **workflows:** approval gates — email confirm/deny + token resume ([b48d8c2](https://github.com/PicPeak/picpeak/commit/b48d8c2eb8e278d3dad8a8afef221a640bcac919))
* **workflows:** booking cutover — wire booking actions + hold documents behind approval gates ([ec33ec7](https://github.com/PicPeak/picpeak/commit/ec33ec7670a4feb1108d1bcbfe34727f63cc8cf9))
* **workflows:** collections-handoff block after dunning exhausts ([b78bd97](https://github.com/PicPeak/picpeak/commit/b78bd979dc4856a78d2022783537b8efd1297d27))
* **workflows:** crash recovery — resume runs orphaned mid-flow ([192d2cb](https://github.com/PicPeak/picpeak/commit/192d2cbc06a0295dbfdc35f05d192ae7ed5273e6))
* **workflows:** data-touching action + condition handlers ([96fb440](https://github.com/PicPeak/picpeak/commit/96fb44045e49c4cbc9b221780f7c725f80751993))
* **workflows:** emit lifecycle events from invoice + quote services ([cc0ba53](https://github.com/PicPeak/picpeak/commit/cc0ba5347d36161e3da2eaff3a1f2d56c582c4a5))
* **workflows:** execution engine core + registry + tests ([1eaef67](https://github.com/PicPeak/picpeak/commit/1eaef67c36a1a1eae6d574d2d44f2a3bdfb43d4c))
* **workflows:** hard cutover of gallery-expiry + dunning + pre-event to flows ([0b6c33e](https://github.com/PicPeak/picpeak/commit/0b6c33e59a2e1ba15210645a65800543eaf6305b))
* **workflows:** implement prepare_event so booking_full/booking_simple are enableable ([4faf5a3](https://github.com/PicPeak/picpeak/commit/4faf5a344a8d6d03cd9f374e093cc9bd8317fa48))
* **workflows:** implement remaining stub actions (prepare_quote, prepare_gallery, reserve_date) ([9414b42](https://github.com/PicPeak/picpeak/commit/9414b42b7fae9f5447c1879e9b2c34d05f7c888d))
* **workflows:** invoice prepared+approved early, dispatch waits; daysBefore in editor; dashboard approvals ([182e655](https://github.com/PicPeak/picpeak/commit/182e655fcf0fa119714e6fd34cad5f33d0087045))
* **workflows:** make approval rows clickable to open the underlying document ([7727b67](https://github.com/PicPeak/picpeak/commit/7727b6714b5654bab067b041ca9c33cf7d9270ab))
* **workflows:** migrate the dunning ladder onto the engine (cutover) ([5259ee9](https://github.com/PicPeak/picpeak/commit/5259ee97053386a63e7bcdf2a5cae22842e5ceaa))
* **workflows:** per-quote booking-workflow picker + quote→invoice (no gallery) built-in ([d14f1d8](https://github.com/PicPeak/picpeak/commit/d14f1d850cc995b2cb1119ba0424f123feba50ec))
* **workflows:** pre-event reminder picks the template GROUP on the block, type stays automatic ([10d091b](https://github.com/PicPeak/picpeak/commit/10d091b55e0c44738b4001a71def6416a8f0aeb0))
* **workflows:** React Flow canvas editor + list + approvals UI ([5c0396d](https://github.com/PicPeak/picpeak/commit/5c0396d0c1bf69ba48b42eacc6d8f145b901a299))
* **workflows:** route webhook node through the delivery pipeline (full Option 1) ([675e41a](https://github.com/PicPeak/picpeak/commit/675e41a2f72c8c23fa5c36b13bc6b95abcb9d570))
* **workflows:** scheduler resumes elapsed wait nodes ([610a3df](https://github.com/PicPeak/picpeak/commit/610a3dfd732fbf37c68bc3edacaed6c99610a2ab))
* **workflows:** schema + permissions (migration 142) ([c818c25](https://github.com/PicPeak/picpeak/commit/c818c25cf2d7a9c9b281107a92528d5923758ab0))
* **workflows:** seed booking + pre-event built-ins, wire event.date_approaching ([62ba905](https://github.com/PicPeak/picpeak/commit/62ba905464387784be7710610a65341569b68e46))
* **workflows:** seed invoice-dunning ladder as an editable built-in flow ([9b557ef](https://github.com/PicPeak/picpeak/commit/9b557efbf347ba585ceac61cfc0b6b3038ef7de6))
* **workflows:** test-fire — safe dry-run of any flow on demand ([e70ddd3](https://github.com/PicPeak/picpeak/commit/e70ddd36b8e9558dc44fc118277539fe6e7425d7))
* **workflows:** warn when disabling a built-in (reverts to legacy, not off) ([c5f131c](https://github.com/PicPeak/picpeak/commit/c5f131cec32826331722ef3705c5f5422e31726d))
* **workflows:** wire booking document actions (prepare_invoice/contract + send_document) ([cf424ef](https://github.com/PicPeak/picpeak/commit/cf424efb4a3a5e0df3a4bb1c753ca24368446816))
* zero-config first run — in-browser admin bootstrap + auto-generated secrets ([bafc96f](https://github.com/PicPeak/picpeak/commit/bafc96f468e3b5cca2ec3291e7b568886755099d))
* zero-config first run — in-browser admin bootstrap + auto-generated secrets ([415bffa](https://github.com/PicPeak/picpeak/commit/415bffa04cd82ea16c1803b67ee432a9ebd4165e))
### Bug Fixes
* **accounting:** 'Save & mark paid' actually pays; incoming invoices appear in tax/export ([3b70a09](https://github.com/PicPeak/picpeak/commit/3b70a09773f22e942ae80962b9b69a7712b08ff3))
* **accounting:** address the-luap PR [#636](https://github.com/PicPeak/picpeak/issues/636) review ([707c5d0](https://github.com/PicPeak/picpeak/commit/707c5d027798bdafb9fe09d7efcbd9ea65330076))
* **accounting:** always show Income/Costs/Result summary on tax page (even with zero costs) ([663daf5](https://github.com/PicPeak/picpeak/commit/663daf50ff92293adcc93aed42333eeba2d08849))
* **accounting:** Banana export is now a tab-separated .txt (actually importable) ([a195067](https://github.com/PicPeak/picpeak/commit/a19506749a449ec0a628da776af5a5bea8a2e46e))
* **accounting:** Banana I&E export uses the 'Category' column (not 'ContraAccount') ([53a16f9](https://github.com/PicPeak/picpeak/commit/53a16f9f6f9b11c224b3ff5f337f8a7fe4dbfc38))
* **accounting:** distinguish Categorized (purple) from Paid (green) ([9514f5c](https://github.com/PicPeak/picpeak/commit/9514f5cb8eadd293d4c85a3849073b59ea93161a))
* **accounting:** emit ISO dates in exports (Postgres returns Date objects) ([0c0fb29](https://github.com/PicPeak/picpeak/commit/0c0fb29770d7559b8b35a1b2a0aae1315485d875))
* **accounting:** label the outgoing-invoice totals block in the tax summary ([f3e77e7](https://github.com/PicPeak/picpeak/commit/f3e77e78079c6869a3a5de062a90f1a04fecde3c))
* **accounting:** lock company-expense to company, first-page categorise preview, auto-refresh inbox ([cee692c](https://github.com/PicPeak/picpeak/commit/cee692c10a1a96ada00a62dbcfc9f998af2c53f7))
* **accounting:** migration 127 must not insert created_at/updated_at into app_settings ([31867ef](https://github.com/PicPeak/picpeak/commit/31867efcb9b9c4f19ca8a7e59dfcbd8eb6157525))
* **accounting:** PDF pager always shown, click categorized→pay, drop duplicate Paid chip ([5fcb96c](https://github.com/PicPeak/picpeak/commit/5fcb96c723a3e5bdf2e93ff3786dfb7ba77b3d24))
* **accounting:** PR [#622](https://github.com/PicPeak/picpeak/issues/622) blockers — CSV formula injection + IMAP double-ingest race ([cd6d578](https://github.com/PicPeak/picpeak/commit/cd6d57839b4753b2848620c5960332cc945580ce))
* **accounting:** PR [#622](https://github.com/PicPeak/picpeak/issues/622) concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap ([a93b6dc](https://github.com/PicPeak/picpeak/commit/a93b6dc232375e1362e091c8868a409b1335dcae))
* **accounting:** tax report 500 on Postgres — drop SQL date() from cost queries ([ea8f6bc](https://github.com/PicPeak/picpeak/commit/ea8f6bc88a4d4ed39c93f80bcfcfbab283fca230))
* **accounting:** tax report cost side queried a non-existent column ([ab65a47](https://github.com/PicPeak/picpeak/commit/ab65a470a009d33558a7167dd2c3c649f785e686))
* **accounting:** tax report degrades gracefully if cost side fails (+ surface the error) ([9f85111](https://github.com/PicPeak/picpeak/commit/9f8511114a78c4c6bca2666b9c59e8325fc66dfb))
* **accounting:** tax-report storno totals + hours-line date on Postgres ([db9e41d](https://github.com/PicPeak/picpeak/commit/db9e41d19846b31b29c5c1be2ee06a7958bb43b0))
* **accounting:** tidy the tax-export scope selector styling ([8deb7e0](https://github.com/PicPeak/picpeak/commit/8deb7e0741a5bf559cb9f9b78350821dc84bdb53))
* **accounting:** UTF-8 BOM on the ledger export so Banana reads it correctly ([74144da](https://github.com/PicPeak/picpeak/commit/74144da45fc0a7a2c2e88d17a23b584ba77e9262))
* **admin-header:** skeleton brand block + move LanguageSelector into profile menu on &lt;sm ([#523](https://github.com/PicPeak/picpeak/issues/523) follow-up) ([b48b5b0](https://github.com/PicPeak/picpeak/commit/b48b5b0000fd95bc149335614eb062dd373fc50a))
* **admin-header:** skeleton brand block + move LanguageSelector into profile menu on &lt;sm ([#523](https://github.com/PicPeak/picpeak/issues/523) follow-up) ([fe10191](https://github.com/PicPeak/picpeak/commit/fe10191b82546473f035435731bf6d6ecca2efd6))
* **admin/events:** delete cascade orphaned photo folders because it read a non-existent column ([#608](https://github.com/PicPeak/picpeak/issues/608)) ([284680e](https://github.com/PicPeak/picpeak/commit/284680e0357db20177e45ab4ab01de0fbcac2a98))
* **admin/events:** delete cascade orphaned photo folders because it read a non-existent column ([#608](https://github.com/PicPeak/picpeak/issues/608)) ([457c956](https://github.com/PicPeak/picpeak/commit/457c9563869156bc4773d873661a75d5115b25db))
* **admin/exports:** Lightroom TXT export joins with comma + drops extension ([#623](https://github.com/PicPeak/picpeak/issues/623)) ([a239fec](https://github.com/PicPeak/picpeak/commit/a239fec9d7b6c01c9649c075a40427b8844f83b8))
* **admin:** graceful logo-img fallback + show sidebar widgets during perm hydration ([#523](https://github.com/PicPeak/picpeak/issues/523) follow-up 2) ([f51b9cf](https://github.com/PicPeak/picpeak/commit/f51b9cf8df2dfba07590b35cc63def689df98c4a))
* **admin:** logo-img fallback + sidebar perm hydration + filename NFD transliteration ([#523](https://github.com/PicPeak/picpeak/issues/523) follow-up 2, [#607](https://github.com/PicPeak/picpeak/issues/607)) ([fcd3ca3](https://github.com/PicPeak/picpeak/commit/fcd3ca36c659eafa47c036f622da8114433b1c74))
* **admin:** stack publish-gallery dialog CTAs so the German label fits ([#670](https://github.com/PicPeak/picpeak/issues/670)) ([748af98](https://github.com/PicPeak/picpeak/commit/748af98f3d3f8c00695b82e94d741a0e10a39a81))
* **admin:** stack publish-gallery dialog CTAs so the German label fits ([#670](https://github.com/PicPeak/picpeak/issues/670)) ([ea2852d](https://github.com/PicPeak/picpeak/commit/ea2852dcb0a0f00318a2c8067405630bcff4eee6))
* **admin:** stop the event-date field crashing the page on backspace ([760a201](https://github.com/PicPeak/picpeak/commit/760a201b6070a4edfe8192bcddce948c5f0c3fec))
* **admin:** stray literal "0" rendered from SQLite integer booleans ([760c3d7](https://github.com/PicPeak/picpeak/commit/760c3d7b67963105f155a43297f165b7e672b074))
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/PicPeak/picpeak/issues/661)) ([349f566](https://github.com/PicPeak/picpeak/commit/349f566e87b33c59f61eb28b8abc5f889e6285d6))
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/PicPeak/picpeak/issues/661)) ([7534447](https://github.com/PicPeak/picpeak/commit/7534447b6c0df4290fd8dac12270673097096f1b))
* **archives:** stream-extract restore for &gt;2 GiB + preserve original_filename via manifest ([#640](https://github.com/PicPeak/picpeak/issues/640)) ([e4e79a0](https://github.com/PicPeak/picpeak/commit/e4e79a0b3a6d3ddbbc2f3cebdcadc89307147248))
* auto-publish release-please PRs without manual approval ([fb64ec0](https://github.com/PicPeak/picpeak/commit/fb64ec0910f8c3ecffb40d85e4f3a08f73503671))
* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2))
* **backup:** make .picpeak roundtrip work on Postgres ([f57462f](https://github.com/PicPeak/picpeak/commit/f57462f7984c64356a9f8acb63c7df3f93909e6e))
* **branding+whatsapp:** preserve customCss through preset switches ([#645](https://github.com/PicPeak/picpeak/issues/645)) + admin-pinned WhatsApp template language ([#647](https://github.com/PicPeak/picpeak/issues/647)) ([cde028e](https://github.com/PicPeak/picpeak/commit/cde028e9199a9ddb09957a87590732f4bd4d7a7b))
* **branding:** force lock = light/dark only; Branding stays the full preset, galleries hide color+mode ([a7c1913](https://github.com/PicPeak/picpeak/commit/a7c19135bb9645a7f95d5fe76581098db305394f))
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c))
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d))
* **branding:** point HTML favicon link at /favicon.ico (the real Safari fix) ([c60e34e](https://github.com/PicPeak/picpeak/commit/c60e34ecae5eadcde47aebcb1c3b32741be576ff))
* **branding:** preserve customCss through preset switches + theme changes ([#645](https://github.com/PicPeak/picpeak/issues/645)) ([7cf2679](https://github.com/PicPeak/picpeak/commit/7cf26795ec12845743a30d48956381f25c6e181c))
* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4))
* **branding:** when a force lock is active, collapse the theme customizer to just the Force control ([1ac653a](https://github.com/PicPeak/picpeak/commit/1ac653ad1b9f47af8cb24cb53ffa60a1e95192fc))
* **ci:** auto-publish release-please PRs without manual approval ([#719](https://github.com/PicPeak/picpeak/issues/719)) ([a3e7232](https://github.com/PicPeak/picpeak/commit/a3e7232b8ed012b8449a76d3e4ea3c5daddd5514))
* **ci:** enable release-PR auto-merge with the PAT, not GITHUB_TOKEN ([e08a33d](https://github.com/PicPeak/picpeak/commit/e08a33d9ea273dc18877743f71f59d64bfc3dfb5))
* **ci:** set GH_REPO in release-please auto-merge step ([0cab43e](https://github.com/PicPeak/picpeak/commit/0cab43ed898c0d080e946837894d676394805eb8))
* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([3feed0f](https://github.com/PicPeak/picpeak/commit/3feed0fae6a5792a7192a529942e08d9872b7e6e))
* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([2a5f0a8](https://github.com/PicPeak/picpeak/commit/2a5f0a8601ba5cb28243b39278ecdc0892388a96))
* conform moved code to eslint indent/quotes, 4-arg mutation callbacks ([2ea26a4](https://github.com/PicPeak/picpeak/commit/2ea26a49620cbe0edfbccf219696b4b386ae50d0))
* **crm:** admin surfaces follow the admin light/dark toggle, not the gallery theme ([#620](https://github.com/PicPeak/picpeak/issues/620)) ([d3266a0](https://github.com/PicPeak/picpeak/commit/d3266a0d1c458e8ae9c57ccd2f650e699544d2d6))
* **crm:** country dropdown on customer onboarding, placed after State/region ([18ffff2](https://github.com/PicPeak/picpeak/commit/18ffff29c3f3da4a9ef49cc29fa70ab50131d5e9))
* **crm:** country dropdown on customer profile billing address too ([fe56d24](https://github.com/PicPeak/picpeak/commit/fe56d24a6b4cc0b191e910a76c0d2c0c58307df8))
* **crm:** drop redundant 'Country (full name)' field ([a2b5ae1](https://github.com/PicPeak/picpeak/commit/a2b5ae17f3014036354f91f8924a5e5c02dbf98e))
* **crm:** editor totals box computed VAT 100× too small ([e9b297c](https://github.com/PicPeak/picpeak/commit/e9b297c162a19da31d53de377b90bfd5cda1b0a7))
* **crm:** localize scheduled-send + installment date + timezone picker ([43b10f9](https://github.com/PicPeak/picpeak/commit/43b10f91c05818ad244f64284aef6b83b0319214))
* **crm:** PR [#603](https://github.com/PicPeak/picpeak/issues/603) review follow-ups + Outlook-proof email design ([a2b2d3f](https://github.com/PicPeak/picpeak/commit/a2b2d3fb313f347bac505b5d1846dce2b679ce55))
* **crm:** pre-event reminder passes raw event_date (fixes "Invalid Date" in the email) ([250b240](https://github.com/PicPeak/picpeak/commit/250b240337733362cb9a74b248ac78b6e9347bf7))
* **crm:** pre-event reminder resolves recipient from the event row, not a non-existent column ([5fbe514](https://github.com/PicPeak/picpeak/commit/5fbe514db6e386eee2eeade548bccbb5bbc5b422))
* **crm:** quote→event fallback resolves an ACTIVE event type, never hardcoded 'wedding' ([2309812](https://github.com/PicPeak/picpeak/commit/23098127a83fb12f6a32809666ec93eef9ac90cb))
* **crm:** recent-activity email placeholder + customer-dashboard locale dates ([ea09a86](https://github.com/PicPeak/picpeak/commit/ea09a86d0505b08f38e70be0c8717df414144a75))
* **deps:** bump qs/brace-expansion overrides + add uuid override for node-cron ([d705059](https://github.com/PicPeak/picpeak/commit/d705059d3c2904184f037bbe0208fe128fdb9b63))
* **downloads:** transliterate accented characters in filename via NFD instead of dropping them ([#607](https://github.com/PicPeak/picpeak/issues/607)) ([620163f](https://github.com/PicPeak/picpeak/commit/620163f2db77cda40b81edcac79a32cbb4fd278f))
* **email,ui:** billing emails follow customer language + readable pay… ([ea86871](https://github.com/PicPeak/picpeak/commit/ea86871b81ed65c47e0de3332510c9a0c8343ba0))
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f))
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81))
* **email:** always log incoming mail to received_emails (was lost on insert error) ([9c18dcf](https://github.com/PicPeak/picpeak/commit/9c18dcf377fca1e3650b0e20c926ecd7728aec9a))
* **email:** fail-fast IMAP timeouts + manual 'Check now' poll ([8a54c6f](https://github.com/PicPeak/picpeak/commit/8a54c6f6b115513ba88a51dea43fad5f8e114c86))
* **email:** guard round-trip test when IMAP username isn't an email ([e258472](https://github.com/PicPeak/picpeak/commit/e258472391b7f91b07a916b7edb6ecc5e5c3c048))
* **email:** IMAP Security dropdown auto-fills the conventional port ([bd402d2](https://github.com/PicPeak/picpeak/commit/bd402d2e89bcb88b1cabdcf3a59a206116a968f9))
* **email:** IMAP Security dropdown matches outgoing — no port in label, manual port ([d04a697](https://github.com/PicPeak/picpeak/commit/d04a6978e9c5df0af5c570df8f35a1a690a0ae9c))
* **email:** log all received mail, not just unseen (90-day lookback + dedup) ([c36797d](https://github.com/PicPeak/picpeak/commit/c36797db2db265ad49049c06cee543b1b528a759))
* **email:** mark required fields on incoming mail to match outgoing SMTP ([fb48ba4](https://github.com/PicPeak/picpeak/commit/fb48ba4cb4cbd2ec5836fc3adaffb79ea8d20787))
* **email:** match IMAP card to SMTP styling + auto-detect mailbox folders ([abb23f0](https://github.com/PicPeak/picpeak/commit/abb23f01c745d4285fbc07994c136a719a66b1d0))
* **email:** recover stuck queue — reinit transporter on config save + manual flush ignores retry cap ([68c967f](https://github.com/PicPeak/picpeak/commit/68c967f9bbc388a3a4605a13d440389929e2561b))
* **email:** resolve recipient language from the queue row's event_id, not just email_data ([10559fd](https://github.com/PicPeak/picpeak/commit/10559fd68e77eb00f3dd79366fcbb7880321e6b8))
* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb))
* **email:** surface the real error on test/save/flush instead of generic toast ([47edbf6](https://github.com/PicPeak/picpeak/commit/47edbf64b5a83c571df62a71b4c3525fe314abfe))
* enable release-PR auto-merge with the PAT so releases actually publish ([97b9853](https://github.com/PicPeak/picpeak/commit/97b9853709fb59a900d70bb2a6bf365d98ae4f86))
* event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders ([b187f58](https://github.com/PicPeak/picpeak/commit/b187f588b4d12af7a7849f8558c0085573d4af76))
* event-reminder, email-language & gallery-publish bugs surfaced during workflow testing ([c8714ca](https://github.com/PicPeak/picpeak/commit/c8714ca42f4d82d50fe611b2a630260ebecbe740))
* **event-types:** renaming a type's slug cascades to events, quotes + reminder template ([415c93a](https://github.com/PicPeak/picpeak/commit/415c93a512f74898d0225ce2e9298f24cc12f60d))
* **events:** NaN from slideshow seed breaks event creation on PostgreSQL ([8c86518](https://github.com/PicPeak/picpeak/commit/8c86518aadae000f8e948b0cd6470730db549c1b))
* **events:** publish-from-draft email carries the real password ([#627](https://github.com/PicPeak/picpeak/issues/627)) ([83b568e](https://github.com/PicPeak/picpeak/commit/83b568ee2ddc007b7d981fd4b46b69810f0165c3))
* **events:** wire customer notifications into both public API entry points ([#647](https://github.com/PicPeak/picpeak/issues/647)) ([f017542](https://github.com/PicPeak/picpeak/commit/f01754247cdb94c5935ad5abbda116841f6c7fba))
* **events:** wire customer notifications into both public API entry points ([#647](https://github.com/PicPeak/picpeak/issues/647)) ([511d647](https://github.com/PicPeak/picpeak/commit/511d647eec656cd223c029a9455420fc0621cc80))
* **flags:** close CRM/accounting feature-gating gaps from the audit ([03fa3d8](https://github.com/PicPeak/picpeak/commit/03fa3d82962d6d6f3cd9630e01258013d567865e))
* **gallery:** admin edits to welcome_message land for returning guests ([#625](https://github.com/PicPeak/picpeak/issues/625)) ([ea6245c](https://github.com/PicPeak/picpeak/commit/ea6245cfdea67bd4668e2100f295433a3d29f7f1))
* **gallery:** guest upload honours general_max_files_per_upload + i18n placeholder interpolates ([#613](https://github.com/PicPeak/picpeak/issues/613)) ([40a4aa2](https://github.com/PicPeak/picpeak/commit/40a4aa2d85d93c9dc1faa69f0e6f8524ff917e29))
* **gallery:** guest upload honours general_max_files_per_upload + i18n placeholder interpolates ([#613](https://github.com/PicPeak/picpeak/issues/613)) ([69b5186](https://github.com/PicPeak/picpeak/commit/69b5186582d56c42cec520abbe5454171f9f666b))
* **gallery:** leave a visible gap between filter bar and hero header ([#624](https://github.com/PicPeak/picpeak/issues/624)) ([178d6da](https://github.com/PicPeak/picpeak/commit/178d6dafb18cd4d30229d745a82af9ce27c41f08))
* **gallery:** publish dialog stuck for password-protected galleries with no inline email ([aa3471e](https://github.com/PicPeak/picpeak/commit/aa3471efe1629f36adeff8774dfdc379d4652e26))
* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/PicPeak/picpeak/issues/654)) ([6193ab7](https://github.com/PicPeak/picpeak/commit/6193ab7f6aafd94b6e2e432ddf170361fd306d4e))
* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/PicPeak/picpeak/issues/654)) ([b1bfd48](https://github.com/PicPeak/picpeak/commit/b1bfd4838e7104e4f85695e180b20222206073ac))
* **hours:** move logActivity out of the entry transactions (SQLite deadlock) ([348955b](https://github.com/PicPeak/picpeak/commit/348955b261713fc9f0b48391a1d4117f6f8c873f))
* **i18n:** replace ASCII quote with U+201D in DE perGuestLimitsDesc ([98e97e3](https://github.com/PicPeak/picpeak/commit/98e97e3cf214c96cdefd99bfedd6724f0b85c41c))
* **i18n:** sweep activity-type translations + Events / API Tokens / Webhooks settings tabs ([f17c654](https://github.com/PicPeak/picpeak/commit/f17c654e146683683f347ae2cd46de9cf3e47989))
* **i18n:** sweep activity-type translations + smart notification fallback ([bc8d333](https://github.com/PicPeak/picpeak/commit/bc8d3330bbe02041760063dcbf5cb5984945a25f))
* **i18n:** sweep Events / API Tokens / Webhooks settings tabs ([997a412](https://github.com/PicPeak/picpeak/commit/997a41293ef7de9bdef1546634cb7e549f9c1331))
* **i18n:** wrap WhatsApp token show/hide aria-label through t() ([a8bb7b4](https://github.com/PicPeak/picpeak/commit/a8bb7b439f6f57af9653ce283c951070bd52f3c2))
* **invoices:** add bank transfer to the mark-paid method list ([e96ef4c](https://github.com/PicPeak/picpeak/commit/e96ef4c5a35bc9e575bc3419fb318a3ee9df1bd6))
* **invoices:** badge held (unsent, no send date) invoices as "Draft" ([e4367e0](https://github.com/PicPeak/picpeak/commit/e4367e028a5228ef50c4bbd522d0777bc7340b52))
* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99))
* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216))
* **invoices:** show "Draft" on the invoice detail page for accumulator drafts ([ca09442](https://github.com/PicPeak/picpeak/commit/ca0944293f66b6465a577340e63d592598915092))
* **invoices:** show sub-cent Rundung in the editor totals preview ([c2bc2b0](https://github.com/PicPeak/picpeak/commit/c2bc2b098e6af3e984e5b06f42e21a8d9501349b))
* **maintenance:** enabling maintenance mode no longer locks admins out ([2493130](https://github.com/PicPeak/picpeak/commit/249313072b08ad79146b397bc08436e9de366d22))
* **maintenance:** never block /admin/* with the maintenance screen ([fdde469](https://github.com/PicPeak/picpeak/commit/fdde4696e7025d7e41dd85eeda26b946f6713e13))
* **messages:** dynamic addresses, branding accent, compose/sync, per-identity SMTP ([7cc1c59](https://github.com/PicPeak/picpeak/commit/7cc1c596613c019db1e64fdc058a9e28bfd11fdc))
* **messages:** dynamic addresses, branding accent, compose/sync, per-identity SMTP ([f9c2b4e](https://github.com/PicPeak/picpeak/commit/f9c2b4ed75b8a22182226a6328be0029c54d7bc4))
* **messages:** make the Messaging feature flag toggleable ([b96ad36](https://github.com/PicPeak/picpeak/commit/b96ad36f5d65ca3774af4b6fa692b08819642de1))
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) nits — server-side search, bare-email recipient, DE i18n ([1e08a4f](https://github.com/PicPeak/picpeak/commit/1e08a4fb156d34ee8ddff69b0a7612001aa6d67e))
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) review — escape reply sender (XSS), gate backend routes, exact customer match ([bb235e7](https://github.com/PicPeak/picpeak/commit/bb235e72e58359f55f8aeccf2f22c671584fdbd7))
* **messages:** show only the mailbox local part in the sidebar (full … ([c622a35](https://github.com/PicPeak/picpeak/commit/c622a35033d739bd04fa8a5388b2670769575bf4))
* **messages:** show only the mailbox local part in the sidebar (full address on hover) ([88fe9f9](https://github.com/PicPeak/picpeak/commit/88fe9f984455ca1509f92a8b1d38342379acfd6c))
* **messages:** show the resolved customer's name in the doc-action modal ([2c5c1d5](https://github.com/PicPeak/picpeak/commit/2c5c1d561bbe567b9d7615e7c2d071d08bb6d63c))
* mirror [#734](https://github.com/PicPeak/picpeak/issues/734) onto decomposed files (PG NaN slideshow seed, SQLite bool renders) ([766351b](https://github.com/PicPeak/picpeak/commit/766351b588bb9d3b270acb1b8c64bd6e242366c2))
* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79))
* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([25bf7bb](https://github.com/PicPeak/picpeak/commit/25bf7bb5239420da078749bac270196df6968581))
* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([1b8747d](https://github.com/PicPeak/picpeak/commit/1b8747dc82763ba6b4da3a55045cab8740da2a13))
* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c))
* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0))
* **pdf:** correct multi-page invoice/quote layout + drop IBAN dup under Swiss QR ([2205b0b](https://github.com/PicPeak/picpeak/commit/2205b0bd687587ff2b54fbddb4770bf69a5ef3b2))
* **projects:** "one customer matches" rule for deal-lineage attach ([f74d8d4](https://github.com/PicPeak/picpeak/commit/f74d8d4e8cd9fa040e067ffd751b183a9673161b))
* **projects:** address review — cross-customer guards + email/queue hardening ([9d13880](https://github.com/PicPeak/picpeak/commit/9d13880f2b177a1a090c4685798e199a1f47b5ec))
* **projects:** clickable milestones/feed, email rollup by customer, PG amount coercion ([c0b6d14](https://github.com/PicPeak/picpeak/commit/c0b6d14d08755f23665eaa8d52dd6a250d6c9074))
* **projects:** enforce single-customer projects (guard event attach + re-label) ([4b1e85c](https://github.com/PicPeak/picpeak/commit/4b1e85c8555b03cbed4abbd80e9cb45b831df6bf))
* **projects:** make email preview fully read-only (no clickable links) ([fa622cf](https://github.com/PicPeak/picpeak/commit/fa622cf2f87f447952b2e6e6e9d7fe60a1c14fb7))
* **projects:** make the whole email row clickable (opens preview) ([84b4a5f](https://github.com/PicPeak/picpeak/commit/84b4a5f049a12bb22941dac733044d90a01fe052))
* **projects:** only link cockpit rows when the target feature is enabled ([2369323](https://github.com/PicPeak/picpeak/commit/236932325971e23db17d6245d1ab2e9f2c1b4eec))
* **projects:** render email preview with its own brand colors, not forced light ([b9a9c01](https://github.com/PicPeak/picpeak/commit/b9a9c018c0d2e0ff6c78ce0a1419ac28449043a4))
* **projects:** scope 'book to project' to the current customer ([89bfb6c](https://github.com/PicPeak/picpeak/commit/89bfb6c5190ec0480168f9b59dc8ce5c2f104068))
* **projects:** scope email rollup to CRM types + re-render unstored previews ([f02fba6](https://github.com/PicPeak/picpeak/commit/f02fba63325115fe416366c5d1c4f38900386cce))
* **projects:** use real events.edit permission for project writes ([f71243e](https://github.com/PicPeak/picpeak/commit/f71243e388e12da840aa70193b4907af2866c039))
* **projects:** wrap long URLs in email preview (no horizontal scroll) ([0cc52f3](https://github.com/PicPeak/picpeak/commit/0cc52f36938be2dc6c8a699afdc55d70831c02a9))
* **reminders:** wrap is_active/is_archived wheres in formatBoolean ([b9d9138](https://github.com/PicPeak/picpeak/commit/b9d91385b43de7ede508884f7cf78b5cf785f853))
* **security:** close BOLA on photo-export + NAT64 SSRF in URL guard ([b8211e9](https://github.com/PicPeak/picpeak/commit/b8211e9944da9e7b1c43a25e2f24c8a2425000cf))
* **security:** close cross-event thumbnail leak, bulk-op ownership bypass, + hardening ([081f3ed](https://github.com/PicPeak/picpeak/commit/081f3edcdffc65a77000cc638e364ea9dc03767f))
* **security:** close NAT64 SSRF + photo-export BOLA + sweep Trivy alerts (GHSA-wmjx-pc37-272r, GHSA-9v4w-jrhx-g5wr) ([6f40db8](https://github.com/PicPeak/picpeak/commit/6f40db859751efc2c931bc981a48148808fd3701))
* **security:** cross-event thumbnail leak, bulk-op ownership bypass + auth hardening ([b732974](https://github.com/PicPeak/picpeak/commit/b732974779803b67097c81ae6bce2de0f2910794))
* **security:** re-apply SVG CSP on the direct favicon route (PR [#603](https://github.com/PicPeak/picpeak/issues/603) blocker) ([1214b6b](https://github.com/PicPeak/picpeak/commit/1214b6b762ce6c763b9a28389c17905d8e47d87f))
* set GH_REPO in release-please auto-merge step ([d00d52a](https://github.com/PicPeak/picpeak/commit/d00d52a2215dfcae34086cf3e10fe4da0aef09c9))
* **settings:** don't insert non-existent created_at into app_settings ([8621338](https://github.com/PicPeak/picpeak/commit/8621338c489cbd5194da6ac22d1fe1bd9d730cb0))
* **settings:** hoist tab-visibility useEffect above isLoading early return ([49bfb45](https://github.com/PicPeak/picpeak/commit/49bfb45332993b919ad4f949a0cd912a85888620))
* **settings:** remove duplicate Mail import that broke the dev server ([5b535f8](https://github.com/PicPeak/picpeak/commit/5b535f86580275eda768fa2d85a8c94bd701f832))
* **settings:** remove duplicate Mail import that crashes the dev server ([4aa6583](https://github.com/PicPeak/picpeak/commit/4aa6583baef55e2c12e9cde7d391156436de518f))
* **setup:** address PR [#714](https://github.com/PicPeak/picpeak/issues/714) review — password UX, script token, race, nits ([286975d](https://github.com/PicPeak/picpeak/commit/286975dc52acf72809476e49ec0393021dd467cb))
* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b))
* **setup:** match first-run logo size to the login page default ([3e69c5d](https://github.com/PicPeak/picpeak/commit/3e69c5df3ffecc42663cba4ad7bf9f18795cedce))
* **slideshow:** deny display-only token on download/upload/feedback (PR [#646](https://github.com/PicPeak/picpeak/issues/646) review) ([e36b330](https://github.com/PicPeak/picpeak/commit/e36b3309ca66404d189d5b218cc1f0eba925e4c7))
* **slideshow:** dip-to-white/black no longer flickers the image ([db8388c](https://github.com/PicPeak/picpeak/commit/db8388c79e44f5d254d984810bd62bfb11effd0f))
* **slideshow:** drop updated_at from event writes ([1e40f82](https://github.com/PicPeak/picpeak/commit/1e40f8296ca59ff0395f6cc09ee452ab62653cdc))
* **slideshow:** feature flag is a master kill-switch, not just admin UI ([759784a](https://github.com/PicPeak/picpeak/commit/759784a4d1cfe7e67c825293760169ad6904f090))
* **slideshow:** fill the viewport instead of black bars ([6ec46de](https://github.com/PicPeak/picpeak/commit/6ec46de0e7bb821ea4e4a7fc2318792b810c3f36))
* **slideshow:** read globals from app_settings, not the missing settings table ([0f4388d](https://github.com/PicPeak/picpeak/commit/0f4388d68ab85049c46e7af566d35f4fbf6e4d02))
* **slideshow:** surface backend error in the live slideshow card ([056f938](https://github.com/PicPeak/picpeak/commit/056f9381de5dbe90243bea409b587b4910050cbf))
* **test:** raise bootCrmDb beforeAll timeout on slideshow suites ([f4b6b89](https://github.com/PicPeak/picpeak/commit/f4b6b8941a30a20615cc87627a0663ff6d03c932))
* **upload:** auto-throttle on low-memory hosts + correct documented RAM minimum ([#628](https://github.com/PicPeak/picpeak/issues/628)) ([714a9f6](https://github.com/PicPeak/picpeak/commit/714a9f6fb1f48ba1316cc240054d5128749581d8))
* **whatsapp:** admin-pinned template language + Arabic locale support ([#647](https://github.com/PicPeak/picpeak/issues/647)) ([4fd7709](https://github.com/PicPeak/picpeak/commit/4fd7709596e7a0dd3fedef63772ddd52ce5561c9))
* **whatsnew:** decode HTML entities and trim em-dash detail in fallback bullets ([5582644](https://github.com/PicPeak/picpeak/commit/5582644dc49330549be2a3a4cdd5b1ba0f21a294))
* **workflows:** backfill existing invoices + anchor dunning grace to due date when enabled ([#750](https://github.com/PicPeak/picpeak/issues/750)) ([9596342](https://github.com/PicPeak/picpeak/commit/9596342d6a9ef107193cfc123487a8061f4a91ca))
* **workflows:** backfill existing invoices + anchor grace to due date when dunning is enabled ([#750](https://github.com/PicPeak/picpeak/issues/750)) ([2c7b351](https://github.com/PicPeak/picpeak/commit/2c7b35145861557021482c0e92f573236ae2676e))
* **workflows:** close review blockers — prefetch-safe approvals + loud gate-edge failure ([98ab717](https://github.com/PicPeak/picpeak/commit/98ab717043e3fdefac0934bf8f4621d523b15e9a))
* **workflows:** dark-mode canvas + readable nodes + structured config ([1734aba](https://github.com/PicPeak/picpeak/commit/1734aba39c199c8dee1b69e9933ddf57579f9d50))
* **workflows:** defer quote.accepted/declined emit until the 15-min response window locks ([539a837](https://github.com/PicPeak/picpeak/commit/539a83711d1996dc9c262365f2c511e7bc445add))
* **workflows:** harden graph validation + refuse enabling unimplemented flows ([d927464](https://github.com/PicPeak/picpeak/commit/d927464778272bd862aa01903179672f4d47368a))
* **workflows:** held booking invoices are 'scheduled', not 'pending_delivery' — so send_document can issue them ([882cfc0](https://github.com/PicPeak/picpeak/commit/882cfc0661b02602bae91b928a46ceea754f7f29))
* **workflows:** make the dashboard pending-approvals card items clickable too ([6e20d58](https://github.com/PicPeak/picpeak/commit/6e20d58487c5e20b08e1d1b4ddd4e76f9e922a79))
* **workflows:** matchFilter strict equality + accurate comment ([dee8d40](https://github.com/PicPeak/picpeak/commit/dee8d40bb3235a908bba514a97a62d3a91a6e131))
* **workflows:** Postgres-safe id capture on workflow inserts ([cede885](https://github.com/PicPeak/picpeak/commit/cede885b04854fd667f65a19697505c0a7c52739))
* **workflows:** scope dunning backfill to its own flow via targetWorkflowId ([da3a77d](https://github.com/PicPeak/picpeak/commit/da3a77dac40a892158167aec939a1458d488a951))
* **workflows:** ship built-ins disabled for first beta + enabled-based mutex + admin sentinel ([5893ecb](https://github.com/PicPeak/picpeak/commit/5893ecb27a0365a79ec04336c5a122b31d31db0e))
* **workflows:** wire a real, SSRF-guarded webhook action (was a silent no-op) ([af7eea8](https://github.com/PicPeak/picpeak/commit/af7eea8b43e37905a79138bcde4b1026dea13050))
### Performance Improvements
* **slideshow:** cache global settings to cut /state DB reads (PR [#646](https://github.com/PicPeak/picpeak/issues/646) review) ([a995131](https://github.com/PicPeak/picpeak/commit/a995131f4266e112c96c6e8cedd5158995ebe899))
### Documentation
* branch model + migration-to-org guide + PR template ([166ef47](https://github.com/PicPeak/picpeak/commit/166ef47611a248c4d517e26d390d87d21f077ca1))
* branch model + migration-to-org guide + PR-template target hint ([d606fcd](https://github.com/PicPeak/picpeak/commit/d606fcd5a425fed3c968ec06b071a386bf558c28))
* prominent migration banner at the top of README ([14bd3e1](https://github.com/PicPeak/picpeak/commit/14bd3e1a6c6cf74378d6f316024584d8941cbcd5))
* prominent migration banner at the top of README ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([5839bba](https://github.com/PicPeak/picpeak/commit/5839bba72a56cc29077f63f7daa038995fb09dfb))
* **readme:** add CRM + accounting to features, tax disclaimer, update contributor ([116743b](https://github.com/PicPeak/picpeak/commit/116743ba438505a52b021f80f678c5a0094d20d4))
* **readme:** add Pixieset to comparison + customer-accounts/CRM/accounting rows ([721f440](https://github.com/PicPeak/picpeak/commit/721f440fa6e810e26ec92d89d14497bd946bcdef))
* **readme:** clarify comparison footnotes — $0 cost caveat + Pixieset video cap ([b043963](https://github.com/PicPeak/picpeak/commit/b0439638bddba819b58d375c0f42cd4df3a837b4))
* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([3528f6b](https://github.com/PicPeak/picpeak/commit/3528f6b8b7e2b537b111f7787d48459a976ef744))
* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([748238e](https://github.com/PicPeak/picpeak/commit/748238e8caf198e3899954804e61a2e179058957))
* require screenshots for UI changes in PRs ([f5b4aa7](https://github.com/PicPeak/picpeak/commit/f5b4aa7a5bc321ffbd33f1c1b92003435a7ee842))
* require screenshots for UI changes in PRs ([8ca7477](https://github.com/PicPeak/picpeak/commit/8ca74776f4d3f7be930a713afe4ac4de594adedd))
* **slideshow:** add Live Slideshow guide + README entries ([16013d1](https://github.com/PicPeak/picpeak/commit/16013d1cf9ad82ee052f905f9702feffde7b67eb))
## [3.44.0](https://github.com/the-luap/picpeak/compare/v3.43.1...v3.44.0) (2026-05-27)
+1 -1
View File
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
## Enforcement
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
+43 -11
View File
@@ -33,12 +33,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
Unsure where to begin? You can start by looking through these issues:
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
### Pull Requests
1. **Fork the repo** and create your branch from `beta`
1. **Fork the repo** and create your branch from `main` (active development)
2. **Install dependencies**:
```bash
cd backend && npm install
@@ -50,7 +50,10 @@ Unsure where to begin? You can start by looking through these issues:
- Linting passes: `npm run lint`
4. **Write tests** if you've added code
5. **Update documentation** if needed
6. **Create a Pull Request**
6. **Attach a screenshot for any UI change** (see below)
7. **Create a Pull Request**
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
## 💻 Development Setup
@@ -79,6 +82,15 @@ cp .env.example .env
docker-compose -f docker-compose.dev.yml up
```
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
```bash
docker compose -f docker-compose.dev.yml up -d --build backend
# (or `frontend`, or both)
```
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
### Running Tests
```bash
@@ -144,17 +156,37 @@ picpeak/
│ └── public/ # Static assets
```
## 🌿 Branch model
PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
## 🔄 Release Process
1. Update version numbers in package.json files
2. Update CHANGELOG.md
3. Create a new release on GitHub
4. Docker images are automatically built and published
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
## 📮 Contact
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
Thank you for contributing! 🎉
-888
View File
@@ -1,888 +0,0 @@
# 🚀 PicPeak Deployment Guide
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 📋 Table of Contents
- [Quick Start](#-quick-start)
- [Prerequisites](#prerequisites)
- [Configuration](#-configuration)
- [Deployment](#-deployment)
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#-external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
## 🚀 Quick Start
### Option 1: Automated Setup Script (Easiest)
For the simplest installation, use our unified setup script:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
### Option 2: Docker with Pre-built Images (Recommended)
```bash
# Clone repository for configuration files
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy and configure environment
cp .env.example .env
nano .env # Edit with your values
# Create required directories
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
# Deploy using pre-built images
docker compose -f docker-compose.production.yml up -d
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
**Available image tags:**
| Channel | Tags | Description |
|---------|------|-------------|
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
| Branch | `main`, `beta` | Latest from each branch |
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
### Option 3: Build from Source
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
cp .env.example .env
nano .env # Edit with your values
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
docker compose build
docker compose up -d
```
## Prerequisites
- Docker and Docker Compose installed
- Domain name (for production)
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🔧 Configuration
### Essential Environment Variables
Generate secure values:
```bash
# JWT Secret
openssl rand -base64 64
# Database Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
# Redis Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
```
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
- Avoid `$` in passwords (recommended - use the commands above)
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env)
Update `.env` with:
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
- `DB_PASSWORD` - PostgreSQL password
- `REDIS_PASSWORD` - Redis password
- `SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### Authentication Security
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you dont set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
```bash
cp frontend/.env.example frontend/.env
```
Update `frontend/.env` with:
- `VITE_API_URL` - Backend API URL
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
- The backend API runs on port **3001**
- The frontend `.env` file MUST point to the correct backend port (3001)
- Default `.env.example` is configured for Docker deployment
### Email Configuration Examples
#### Gmail
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
```
#### SendGrid
```env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
```
## 📦 Deployment
### Using Pre-built Images (Fastest)
```bash
# Pull latest images from GitHub Container Registry
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Start services using production compose file
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
# View running containers
docker compose ps
```
### Access Points
By default, services are exposed on:
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
### Initial Admin Setup
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
#### Finding the Auto-Generated Admin Password
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
**Option 1: Search Docker logs for admin password** (recommended)
```bash
# Find the auto-generated admin password in logs
docker compose logs backend | grep "Admin password"
```
You should see output like:
```
✅ Admin password generated: BraveTiger6231!
```
**Option 2: View the complete initialization logs**
```bash
# View the complete admin setup logs
docker compose logs backend | grep -A 10 "Admin user created"
```
**Option 3: Check the saved credentials file**
```bash
# The password is also saved in the backend container
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
```
**Option 4: Use the helper script**
```bash
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password (displays new password in console)
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
#### Important Security Notes
- **Login requires the email address**, not username
- When resetting password, the new password is displayed once in the console - save it immediately
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
## 🔐 First Login
After deployment, you must complete the first login process which includes mandatory password change for security.
### Step 1: Locate Your Admin Password
1. **Find the auto-generated password** from the credentials file:
```bash
# Docker deployment
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
# Or directly from the host (if you have access)
cat data/ADMIN_CREDENTIALS.txt
```
2. **Note the admin email** (default: `admin@example.com` unless customized)
### Step 2: Access Admin Panel
1. Navigate to your frontend domain and open the admin section:
- `http://your-domain.com/admin` (behind reverse proxy)
- `http://localhost:3000/admin` (Docker local)
The backend at `:3001` serves API only and does not serve the admin UI.
2. Login using:
- **Email**: `admin@example.com` (or your custom admin email)
- **Password**: The auto-generated password from the logs
### Step 3: Mandatory Password Change
Upon first login, the system will **automatically redirect** you to change your password:
1. **You cannot skip this step** - it's enforced for security
2. Enter the current auto-generated password
3. Create a new secure password meeting these requirements:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character (!@#$%^&*)
### Security Best Practices for New Password
- **Use a unique password** not used elsewhere
- **Consider a password manager** for generation and storage
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
- **Avoid personal information** (names, dates, etc.)
- **Save securely** - you cannot recover this password easily
### If You Lose Access
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./picpeak-setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
```env
# .env
ADMIN_EMAIL=your-email@yourdomain.com
```
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
```
### Switching Channels
To switch between channels:
```bash
# Edit your .env file
nano .env
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
# Pull the new images and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
### Routing Schema
PicPeak consists of two services that need to be routed correctly:
| Path | Service | Port | Description |
|------|---------|------|-------------|
| `/api/*` | Backend | 3001 | All API endpoints |
| `/photos/*` | Backend | 3001 | Protected photo files |
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
| `/uploads/*` | Backend | 3001 | Upload files |
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`:
```nginx
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Backend: API endpoints
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend: Protected media files
location ~ ^/(photos|thumbnails|uploads)/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend: Everything else (React SPA)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Option 2: Traefik
Add labels to `docker-compose.override.yml`:
```yaml
version: '3.8'
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
backend:
labels:
- "traefik.enable=true"
# API endpoints
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
# Protected media files
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
```
### Option 3: Caddy
Create a `Caddyfile`:
```caddyfile
your-domain.com {
# Backend: API endpoints
handle /api/* {
reverse_proxy localhost:3001
}
# Backend: Protected media files
handle /photos/* {
reverse_proxy localhost:3001
}
handle /thumbnails/* {
reverse_proxy localhost:3001
}
handle /uploads/* {
reverse_proxy localhost:3001
}
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
handle {
reverse_proxy localhost:3000
}
}
```
### SSL Certificates
For any reverse proxy, you can use Let's Encrypt:
```bash
# With Certbot
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
# Or use your reverse proxy's built-in ACME support
```
## 📂 External Media Library
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
### How It Works
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
### Configuration
Add the following to your `.env` file:
```bash
# Path where your photo library is stored on the host
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
```
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
```yaml
services:
backend:
environment:
- EXTERNAL_MEDIA_ROOT=/external-media
volumes:
- /path/to/your/photos:/external-media:ro # read-only is recommended
```
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
### Folder Structure
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
```
/path/to/your/photos/
├── wedding-smith-2026/
│ ├── individual/
│ │ ├── IMG_0001.jpg
│ │ ├── IMG_0002.jpg
│ │ └── ...
│ └── collages/
│ ├── group-photo.jpg
│ └── ...
├── corporate-event/
│ ├── individual/
│ │ └── ...
│ └── collages/
│ └── ...
```
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
### Usage
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
- Recursively scan the selected folder
- Classify photos by subfolder name (`individual/` or `collages/`)
- Deduplicate by filename (keeps the largest file if duplicates exist)
- Extract image dimensions for gallery layout
- Register the photos in the database
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
### Limitations
- **Images only** — video files are not supported for external media.
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
### Troubleshooting
| Problem | Solution |
|---------|----------|
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
## 🔧 Maintenance
### Viewing Logs
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f backend
docker compose logs -f frontend
```
### Backup
#### Manual Backup
```bash
# Database backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
# Files backup
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
```
#### Automated Backup
The application includes a built-in backup service. Configure it in the admin panel:
1. Login to admin panel
2. Go to Settings → Backup
3. Configure destination and schedule
4. Enable backup service
### Updates
#### Method 1: Using Pre-built Images (Recommended)
```bash
# Pull latest changes (for configuration updates)
git pull
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml pull
# Restart with new images
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
# Verify services are healthy
docker compose -f docker-compose.production.yml ps
```
#### Method 2: Building from Source
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker compose down
docker compose build --no-cache
docker compose up -d
# Verify services are healthy
docker compose ps
```
#### Specific Version or Channel Updates
To use a specific version or switch channels, update your `.env` file:
```bash
# Edit .env to change the channel or pin to a specific version
nano .env
# Options for PICPEAK_CHANNEL:
# - stable (recommended, production-ready)
# - beta (early access to new features)
# - v2.3.0 (pin to specific stable version)
# - v2.3.0-beta.1 (pin to specific beta version)
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard will notify you when updates are available for your configured channel.
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
```bash
docker exec picpeak-backend npm run migrate
```
## 🚨 Troubleshooting
### Common Issues
#### 502 Bad Gateway / Login Failures
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
1. **CORS Configuration Errors**:
```bash
# WRONG - Missing port will cause CORS errors
FRONTEND_URL=http://10.0.252.12
# CORRECT - Include the port you're accessing from
FRONTEND_URL=http://10.0.252.12:3000
```
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
2. **After Container Restarts**:
- Nginx may have cached old container IPs
- Solution: `docker restart picpeak-frontend`
- Always wait 30-60 seconds for health checks
3. **Backend Not Starting After Migrations**:
- The logs may only show migrations completed
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
- Should see `node server.js` process
4. **Login After Fresh Install**:
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
- Email: `admin@example.com` (or your custom admin email from .env)
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
- Remember: Password MUST be changed on first login
5. **Complete Fix Sequence**:
```bash
# 1. Fix your .env file URLs
# 2. Full restart
docker-compose down
docker-compose up -d
# 3. Wait for healthy status
sleep 60
docker ps # All should show (healthy)
# 4. Test backend directly
curl http://localhost:3001/health
# 5. Test through frontend
curl http://localhost:3000/api/public/settings
```
#### Port Already in Use
```bash
# Check what's using the port
sudo lsof -i :3000
sudo lsof -i :3001
# Change ports in .env
FRONTEND_PORT=3002
BACKEND_PORT=3003
```
#### Docker Compose Variable Substitution Errors
If you see warnings like:
```
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
```
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
2. **Alternative**: Escape `$` as `$$` in your .env file
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
#### Permission Errors
```bash
# Fix ownership
sudo chown -R 1000:1000 events data logs backup storage
chmod -R 755 events data logs backup storage
```
#### Database Connection Issues
```bash
# Check if database is running
docker compose ps
docker compose logs postgres
# Test connection
docker exec picpeak-postgres pg_isready
```
#### Email Not Sending
- Verify SMTP settings in .env
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
- For Gmail, use app-specific password
- Check logs: `docker compose logs backend | grep email`
### Health Checks
```bash
# Backend health
curl http://localhost:3001/api/health
# Frontend health
curl http://localhost:3000
# Database health
docker exec picpeak-postgres pg_isready
```
### Useful Commands
```bash
# Enter backend container
docker exec -it picpeak-backend sh
# Enter database
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
# Reset admin password
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
# Check disk usage
df -h
du -sh events/ storage/ backup/
# View running processes
docker compose top
```
## Security Recommendations
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
2. **Firewall**: Only expose necessary ports (80, 443)
3. **Secure passwords**: Use strong, unique passwords for all services
4. **Regular updates**: Keep Docker images and system packages updated
5. **Backup strategy**: Set up automated backups and test restoration
6. **Monitor logs**: Regularly check logs for suspicious activity
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
## Support
For issues and questions:
- Check logs first: `docker compose logs`
- Review documentation in the repository
- Check existing issues on GitHub
- Create a new issue with:
- Error messages
- Log output
- Environment details (without secrets)
- Steps to reproduce
+504 -85
View File
@@ -1,152 +1,571 @@
# PicPeak
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
**Self-hosted photo sharing for event photographers.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/theluap)
[Live Demo](https://demo.picpeak.app) · [Deployment Guide](DEPLOYMENT_GUIDE.md) · [Homepage](https://www.picpeak.app)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
</div>
---
PicPeak lets you create password-protected, time-limited photo galleries for your clients — hosted on your own server. No subscriptions, no storage limits, no third-party access to your photos.
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## Demo
## 🎮 Live Demo
Try it out at [demo.picpeak.app](https://demo.picpeak.app).
Try PicPeak without installing anything:
Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login with `demo@picpeak.app` / `Demo2026!`
| | |
|---|---|
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
| **Email** | `demo@picpeak.app` |
| **Password** | `Demo2026!` |
> The demo resets periodically.
> The demo resets periodically. Uploaded content may be removed without notice.
## Features
## 🌟 Why Choose PicPeak?
**Gallery Management** — Create galleries, upload photos via drag & drop, set passwords and expiration dates. Galleries auto-archive when they expire. Events start as drafts so you can upload and prepare before notifying the client.
Unlike expensive SaaS solutions, PicPeak gives you:
**Client Experience** — Responsive galleries that look great on any device. Guests can browse, download individual photos or everything at once. Optional guest uploads and feedback (likes, comments, ratings).
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
- **🔒 Complete Data Control** - Your photos stay on your server
- **🎨 White-Label Ready** - Full branding customization
- **📱 Mobile-First Design** - Beautiful on all devices
- **🚀 Lightning Fast** - Optimized performance and caching
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
**Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name.
## ✨ Key Features
**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI.
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
- 🔗 **External Media (Reference Mode)** - Browse and import from a readonly external folder library without copying originals
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
**Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery.
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
**External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand.
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently.
### For Studios — CRM & Accounting (Beta · off by default)
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
**Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics.
## 🚀 Quick Start
**Video Support** — Upload and stream MP4, WebM, MOV alongside photos. FFmpeg bundled via npm.
**Multiple Admins** — Role-based access control with super admin, admin, and editor roles.
## Quick Start
Get PicPeak running in under 5 minutes:
```bash
git clone https://github.com/the-luap/picpeak.git
# Clone the repository
git clone https://github.com/PicPeak/picpeak.git
cd picpeak
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Edit .env — set at least JWT_SECRET and passwords
# Start with Docker Compose
docker compose up -d
# Access at http://localhost:3000
```
Open `http://localhost:3000` and log in with the credentials from your `.env`.
### First run — create your admin account
> **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable.
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration.
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
## Screenshots
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
<details>
<summary>Admin Dashboard</summary>
Note on Docker file permissions
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
<img src="docs/screenshot-dashboard.png" alt="Admin Dashboard" width="800" />
</details>
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
<details>
<summary>Event Management</summary>
## 🔄 Release Channels
<img src="docs/screenshots-events.png" alt="Event Management" width="800" />
</details>
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 46 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
<details>
<summary>Analytics</summary>
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
<img src="docs/screenshot-analytics.png" alt="Analytics" width="800" />
</details>
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
## Comparison
### Switching Channels
| | PicPeak | PicDrop | Scrapbook.de |
|---|---|---|---|
| Self-hosted | Yes | No | No |
| Monthly cost | $0 | $29-199 | 19-99 EUR |
| Storage | Unlimited | 50-500 GB | 100-1000 GB |
| Custom branding | Full | Limited | Limited |
| Open source | Yes | No | No |
| API | Yes | Paid | No |
## Tech Stack
- **Backend:** Node.js, Express, PostgreSQL (or SQLite)
- **Frontend:** React, TypeScript, Tailwind CSS
- **Infrastructure:** Docker, Nginx, Redis
- **Processing:** Sharp (images), FFmpeg (video)
## Release Channels
**Stable** (`stable` / `latest`) — Production-ready. Use this for real deployments.
**Beta** (`beta`) — Early access to new features. May have rough edges.
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# Set in .env
PICPEAK_CHANNEL=stable # or beta
# For stable releases (default)
PICPEAK_CHANNEL=stable
# Update
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
Then update your containers:
```bash
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard notifies you when updates are available.
### Update Notifications
## Contributing
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
We welcome contributions — bug fixes, features, translations, documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
```bash
UPDATE_CHECK_ENABLED=false
```
## Documentation
## 📖 Documentation
- [Deployment Guide](DEPLOYMENT_GUIDE.md) — Installation, configuration, reverse proxy, external media
- [Admin API (OpenAPI)](docs/picpeak-admin-api.openapi.yaml) — Machine-readable API spec
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
- [Security Policy](SECURITY.md)
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
## Contributors
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
Thanks to the people whose code, reports, and feedback have shaped PicPeak:
Project meta:
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, lazy-loaded folder tree picker, admin-email picker, self-hosted webfont system, gallery header/banner decoupling, and several typed-API refactors.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, gallery-loading skeleton work, mobile-lightbox overhaul, admin-events search-counter fix, photo-count column, and bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter.
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
If you've contributed and aren't listed here, please open a PR.
## 🌐 Public Landing Page
Spotlight your studio with a customizable marketing page at `/`:
## License
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
- Use **Reset to default** anytime to restore the bundled template.
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
MIT — use it for personal or commercial projects.
## 🎯 Use Cases
Perfect for:
- 💒 **Wedding Photographers** - Share ceremony photos securely
- 🎂 **Event Photography** - Birthday parties, corporate events
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
## 🏗️ Tech Stack
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 💾 Storage Backends
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|---|---|---|
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
| Admin UI upload | ✅ | ✅ |
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
| Backups | ✅ | ✅ |
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
### Switching to an S3-compatible backend
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
## 🔔 Webhooks
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
### Event types
| Event | Fires when |
|---|---|
| `event.created` | Gallery created (admin or API) |
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
### Payload shape
```json
{
"id": "delivery-uuid",
"type": "event.published",
"created_at": "2026-04-28T05:25:00.000Z",
"data": {
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
}
}
```
Also sent on every request:
- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
- `User-Agent: PicPeak-Webhooks/1.0`
### Verifying signatures
**Node.js**
```js
const crypto = require('crypto');
function verify(secret, rawBody, signature) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
```
**Python**
```python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
```
**curl + openssl** (one-liner for a quick replay)
```sh
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
```
### Retries + observability
- `2xx` → success, recorded with latency
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
### SSRF protection
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
## 💻 System Requirements
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
decodes the full uncompressed frame before resize, and the default two
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
- **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher
- **Database**: SQLite (included) or PostgreSQL 12+
### Docker Requirements (Recommended)
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Low-memory hosts
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
tuning the upload-processor concurrency down. The backend auto-detects
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
a one-shot warning. You can pin the value explicitly in `.env`:
```env
# Single worker loop — slower batch processing, lower peak RSS
UPLOAD_PROCESSOR_CONCURRENCY=1
```
The trade-off is throughput: a single worker processes one photo at a
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
note**: if the backend dies under memory pressure, the gallery serves
`503 Service Unavailable` on thumbnails until Docker's
`restart: unless-stopped` brings the container back. Persistent 503s
during/after an upload batch on a low-memory host are almost always this.
### Video Support Requirements
When enabling video uploads, consider these additional resources:
| Resource | Recommendation | Notes |
|----------|----------------|-------|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|---------|---------|---------|--------------|----------|
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GBUnlimited*** |
| Client Uploads | ✅ | ✅ | ✅ | Limited |
| API Access | ✅ | Paid | ❌ | ❌ |
| Open Source | ✅ | ❌ | ❌ | ❌ |
| Customer Accounts | ✅ | ❌ | ❌ | ✅ |
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
## 🛡️ Security
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>👆 Click to see more interface details</summary>
#### What makes PicPeak's interface special:
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🗺️ Roadmap
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
### 🚧 Beta Features (Use at your own risk)
These features are currently in beta testing and may have limited functionality or stability:
| Feature | Description | Status |
|---------|-------------|--------|
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
## ☕ Support the Project
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
<p align="left">
<a href="https://buymeacoffee.com/theluap" target="_blank">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
### 👥 Contributors
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
### 🤖 AI-Assisted Development
This project was generated with the assistance of AI technology, but has been:
- ✅ **Fully tested end-to-end** by human developers
- 🔒 **Security audited** with comprehensive security checks
- 👨‍💻 **Human-reviewed** for code quality and best practices
- 🧪 **Production-tested** in real-world scenarios
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
report and the accountant exports) ship seeded content and computed
figures that are intended as a **starting point only**:
- **Contract blocks** (image rights, NDA, model release, cancellation,
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
Every operator must have their lawyer review and adapt them before
sending any contract to a customer.
- **QR-bills and SEPA EPC payloads** are rendered from the data you
typed. Picpeak is open source — please scan a test invoice with your
bank's app to check the QR actually works. We are not responsible for
any mistakes that come from sending an invoice with bad data on it.
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
from the data you enter and the defaults you configure. They are
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
rate) and filing duties differ by country and change over time. **Every
operator must check their own tax / VAT regulations and verify the
numbers with their accountant / Treuhänder / tax authority before
relying on any figure or export.** Picpeak makes no warranty that the
output is correct for your jurisdiction or situation.
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
enabling the Contracts, Invoices or Accounting features.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
---
<p align="center">
<a href="https://www.picpeak.app">Homepage</a> · <a href="https://demo.picpeak.app">Live Demo</a> · <a href="DEPLOYMENT_GUIDE.md">Docs</a> · <a href="https://github.com/the-luap/picpeak/issues">Issues</a>
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
</p>
+94
View File
@@ -0,0 +1,94 @@
# Release Process
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
## TL;DR
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 46 weeks**, or sooner if `main` has been quiet and ready for promotion.
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
## Cadence target
46 weeks between stable releases is the working target. Reasoning:
- Long enough that each stable carries meaningful changes worth the upgrade burden.
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 46 week window, which gives natural promotion candidates).
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
## Promotion criteria
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
## How a stable release is cut
The actual mechanics, in order:
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
2. **Create the release branch from the `main` tip.**
```bash
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
```
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `stable` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
## Versioning
PicPeak follows [Semantic Versioning](https://semver.org/) with one project-specific convention:
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
- **Test-only changes** — same.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
## When this doc is wrong
If you find yourself working around something here, update the doc before doing the workaround. The point of a written process is that future-you doesn't have to remember the workaround.
+3 -3
View File
@@ -16,7 +16,7 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+16 -3
View File
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
@@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🔑 First Login — Create Your Admin
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
### Direct Access (Simplest)
@@ -472,7 +485,7 @@ sudo -u picpeak node scripts/reset-admin-password.js
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
@@ -550,4 +563,4 @@ sudo ./picpeak-setup.sh --native \
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
+10
View File
@@ -9,6 +9,16 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
# Generate with: openssl rand -base64 32
#MFA_ENCRYPTION_KEY=
# Auth cookie Secure flag
# unset - default: 'auto' in production, false in dev (#427)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
+41 -7
View File
@@ -7,7 +7,7 @@ ARG VCS_REF
ARG VERSION
# Add labels for GitHub Container Registry
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
@@ -27,12 +27,24 @@ FROM node:22-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
# image always picks up current Alpine security updates instead of reusing a
# stale cached upgrade layer.
ARG CACHEBUST=1
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -43,7 +55,21 @@ RUN npm install -g npm@10
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
# PDFKit + the web UI use) are registered with fontconfig further down so the
# logo's text renders in its actual typeface, not a fallback.
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
# documents (see docs/accounting-inbound-invoices.md).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
fc-cache -f
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -55,6 +81,14 @@ COPY --chown=nodejs:nodejs . .
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
# rasterises an SVG logo its <text> renders in the actual brand typeface
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
# internal family name and recurses into the per-family subdirectories.
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
fc-cache -f /app/assets/fonts
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
@@ -0,0 +1,242 @@
/**
* Integration test for GET /api/admin/system-health/backup-coverage.
*
* Pins the Stage C diagnostic that tells admins what the next
* "Run Backup Now" will include, skip, or silently miss.
*
* Test surface:
* 1. Empty / fresh install → default seed (7 paths), inline mode,
* no DB dump on file yet, no drift
* 2. Toggle `include_in_default=false` → coverage flips to
* 'skipped-by-toggle'
* 3. Feature_flag gating reflects the actual app_settings value
* (events/archived ⇄ backup_include_archived)
* 4. Drift detection: a top-level subdir on disk with no
* `backup_paths` row is flagged in `unconfiguredOnDisk`
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
* 6. Scheduled-only mode + recent dump → `database.ok = true`
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
* and `lastDumpStale = true`
*
* Same auth/permission pass-through strategy as
* adminBackupIntegrity.test.js — we exercise the route's logic,
* not the auth middleware.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
let cleanup;
let storagePath;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkdir(rel) {
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
}
function rmdir(rel) {
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
}
async function restoreDefaultPaths() {
await db('backup_paths').del();
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
}
beforeEach(async () => {
await restoreDefaultPaths();
await db('database_backup_runs').del().catch(() => {});
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
});
it('returns the canonical 7 paths + database block on a fresh install', async () => {
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
const { report } = res.body;
expect(report.paths.map((p) => p.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Default mode is inline — no inline_dump setting present means
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
expect(report.database.mode).toBe('inline');
expect(report.database.ok).toBe(true);
expect(report.summary).toMatchObject({
configuredCount: 7,
tableMissingFallbackInUse: false,
});
});
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
expect(thumbnails.coverage).toBe('skipped-by-toggle');
expect(thumbnails.includeInDefault).toBe(false);
});
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
// backup_include_archived not set → archived skipped via flag
const off = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
expect(archivedOff.featureFlag).toBe('backup_include_archived');
expect(archivedOff.featureFlagValue).toBe(null); // unset
// Now set the flag — but path is missing on disk, so coverage
// resolves to 'missing-on-disk', proving the flag was honoured.
await db('app_settings').insert({
setting_key: 'backup_include_archived',
setting_value: JSON.stringify(true),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const on = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOn.featureFlagValue).toBe(true);
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
});
it('detects unconfigured top-level subdirs as drift', async () => {
mkdir('events/active'); // configured
mkdir('plugin-store/cache'); // DRIFT
mkdir('shiny-new-feature/data'); // DRIFT
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
'plugin-store',
'shiny-new-feature',
]));
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
rmdir('plugin-store');
rmdir('shiny-new-feature');
});
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
mkdir('backups');
mkdir('tmp');
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
expect.arrayContaining(['backups', 'tmp']),
);
rmdir('backups');
rmdir('tmp');
});
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
fs.writeFileSync(recentDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(), // just now
status: 'completed',
backup_type: 'pg',
file_path: recentDump,
file_size_bytes: fs.statSync(recentDump).size,
destination_path: recentDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.mode).toBe('scheduled-only');
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
expect(res.body.report.database.lastDumpStale).toBe(false);
expect(res.body.report.database.ok).toBe(true);
});
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
fs.writeFileSync(oldDump, 'pretend old dump');
// 48 hours ago — well past the 26h staleness threshold. ISO
// string instead of a Date object because knex-sqlite's datetime
// serialisation has a quirk where some Date instances coerce to
// '[object Object]' on insert (the test 6 "recent dump" case
// passes only because `new Date()` happens to round-trip safely;
// arithmetic Dates don't).
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
await db('database_backup_runs').insert({
started_at: stale,
completed_at: stale,
status: 'completed',
backup_type: 'pg',
file_path: oldDump,
file_size_bytes: fs.statSync(oldDump).size,
destination_path: oldDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.lastDumpStale).toBe(true);
expect(res.body.report.database.ok).toBe(false);
// Top-level summary reflects the failed DB check.
expect(res.body.report.summary.databaseOk).toBe(false);
expect(res.body.report.summary.overallOk).toBe(false);
});
});
@@ -0,0 +1,140 @@
/**
* Integration test for GET /api/admin/system-health/backup-integrity.
*
* Auth + permission middleware are mocked to pass-through so the test
* focuses on the route's own behaviour: scope-param validation, the
* successResponse envelope, and that the underlying service report
* surfaces correctly in the JSON body.
*
* The verifier service itself is exercised against the real schema
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Pass-through auth so we don't need to mint JWTs.
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
// Pass-through permissions so settings.view always allows.
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
let db;
let customerId;
let app;
let storagePath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
storagePath = process.env.STORAGE_PATH;
// Mount the route on a minimal Express app. Cold-require after
// bootCrmDb so the route's downstream `require('../database/db')`
// sees the same db instance.
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('contracts').del().catch(() => {});
await db('invoices').del().catch(() => {});
await db('quotes').del().catch(() => {});
});
it('returns a report envelope when nothing references any path', async () => {
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
expect(res.body.report.summary).toMatchObject({
totalRows: 0,
missingFiles: 0,
hashMismatches: 0,
verifiedOk: 0,
existsButNoHash: 0,
});
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
it('surfaces a missing file in the response payload', async () => {
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-MISSING',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
created_at: new Date(),
});
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body.report.summary.missingFiles).toBe(1);
expect(res.body.report.missing[0]).toMatchObject({
table: 'contracts',
column: 'signed_pdf_path',
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
});
});
it('honours the ?scope=invoice filter', async () => {
// Seed both an invoice and a contract with missing files. With
// scope=invoice the contract row must not appear.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'INV-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
due_date: '2026-01-31',
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
created_at: new Date(),
});
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
created_at: new Date(),
});
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'invoice' });
expect(res.status).toBe(200);
expect(res.body.report.scopes).toEqual(['invoice']);
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
});
it('rejects an unknown scope with 400 + a code', async () => {
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'gallery' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
expect(res.body.validScopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
});
@@ -0,0 +1,88 @@
/**
* Regression net for the business-docs coverage gap fixed in this PR.
*
* Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed
* list of storage subdirectories (events/active, events/archived,
* thumbnails, previews, heroes, uploads) and silently omitted the
* entire `business-docs/` tree. That meant every CRM PDF + signature
* drawing — quotes, contracts (system-rendered + wet uploads),
* invoices, Storno, imported historical invoices, and the customer
* signature PNG/JPG drawn on the public signing page — fell outside
* the in-app scheduled backup, leaving every `*_path` column on
* `quotes` / `contracts` / `invoices` as a broken FK after restore.
*
* The fix is a single `scanDirectory(business-docs, ...)` call. This
* suite pins the contract so a future refactor of the walker cannot
* silently drop business-docs again.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
describe('backupService — business-docs is in the backup walker', () => {
let cleanup;
let backupService;
let storagePath;
beforeAll(async () => {
({ cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
// Cold-require after bootCrmDb so backupService picks up the same
// db instance + STORAGE_PATH the test harness configured.
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seed(relPath, content = 'dummy bytes for backup test') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
it('does not error when business-docs is absent', async () => {
// Fresh harness has no business-docs/ tree at all. The walker
// must short-circuit on ENOENT rather than throw — installs that
// never used CRM features have to keep backing up fine.
await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array));
});
it('picks up every CRM-relevant business-docs subdirectory', async () => {
// Seed one file in each of the five subpaths the renderer + import
// routes write to. The signature path is the one most prone to be
// forgotten — it lives one level deeper than the others (per-
// contract subfolder, not per-year).
seed('business-docs/quote/2026/Q-001.pdf');
seed('business-docs/contract/2026/C-001.pdf');
seed('business-docs/contract/signatures/42/customer-1700000000000.png');
seed('business-docs/invoice/2026/INV-001.pdf');
seed('business-docs/invoice-imports/2026/scan.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'business-docs/quote/2026/Q-001.pdf',
'business-docs/contract/2026/C-001.pdf',
'business-docs/contract/signatures/42/customer-1700000000000.png',
'business-docs/invoice/2026/INV-001.pdf',
'business-docs/invoice-imports/2026/scan.pdf',
]));
});
it('walks newly-created business-docs files without needing a restart', async () => {
// The walker reads the filesystem live on every call; this guards
// against a future "cache the scan result at boot" optimisation
// that would miss freshly-written PDFs (which is exactly what
// happens during normal operation — every send writes a new file).
seed('business-docs/invoice/2027/INV-NEW.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf');
});
});
@@ -0,0 +1,180 @@
/**
* Pins the Stage-B refactor that lifted the file-backup walker's
* subdirectory list out of hard-coded JS into the `backup_paths`
* table seeded by migration 109.
*
* Scenarios:
* 1. Walker reads canonical seed → all 7 default subdirs walked
* 2. include_in_default=false on one row → that subdir is skipped
* 3. New row inserted at runtime → walker picks it up without restart
* 4. feature_flag gating → row only walked when the named app_settings
* boolean is truthy (mirrors historical `includeArchived` behavior)
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
* in depth — never silently scans nothing)
*
* Why not stub `db('backup_paths')`: the whole point of Stage B is
* that the walker is now data-driven, so the test has to actually
* mutate the table and observe the walker's output change. Stubs
* would re-introduce the hard-coding the refactor is meant to remove.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Restore canonical seed before every test. Tests mutate this table
// freely; the next test starts from a known state.
await db('backup_paths').del();
const {
DEFAULT_PATHS,
} = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
});
it('migration 109 seeds the canonical 7 paths', async () => {
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
expect(rows.map((r) => r.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Only events/archived is gated by a feature flag.
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
'events/archived',
]);
});
it('walks every default subdir when files are present', async () => {
seedFile('events/active/E1/a.jpg');
seedFile('thumbnails/E1/a.jpg');
seedFile('previews/E1/a.jpg');
seedFile('heroes/E1/hero.jpg');
seedFile('uploads/intake/x.bin');
seedFile('business-docs/quote/2026/Q-001.pdf');
// events/archived is gated — left out of this test; covered below.
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'events/active/E1/a.jpg',
'thumbnails/E1/a.jpg',
'previews/E1/a.jpg',
'heroes/E1/hero.jpg',
'uploads/intake/x.bin',
'business-docs/quote/2026/Q-001.pdf',
]));
});
it('skips a path when include_in_default is toggled off', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('picks up a new path inserted at runtime — no restart needed', async () => {
// Simulates a future feature shipping its own subdirectory and
// self-healing a `backup_paths` row at boot.
await db('backup_paths').insert({
path: 'plugin-store',
include_in_default: true,
feature_flag: null,
display_order: 200,
description: 'Hypothetical future feature payload',
created_at: new Date(),
updated_at: new Date(),
});
seedFile('plugin-store/cache/payload.bin');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('plugin-store/cache/payload.bin');
});
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
seedFile('events/active/E1/active.jpg');
seedFile('events/archived/E2/archived.jpg');
// backup_include_archived=false → archived/ is skipped.
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
const relsOff = filesOff.map((f) => f.relativePath);
expect(relsOff).toContain('events/active/E1/active.jpg');
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
// backup_include_archived=true → archived/ is included.
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
const relsOn = filesOn.map((f) => f.relativePath);
expect(relsOn).toContain('events/archived/E2/archived.jpg');
});
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
// Defense in depth: even if seed-and-self-heal both failed, the
// walker must still cover the historical set so "Run Backup Now"
// cannot silently degrade to no-op.
await db('backup_paths').del();
seedFile('events/active/E1/photo.jpg');
seedFile('business-docs/quote/2026/Q-002.pdf');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
});
it('legacy boolean call signature still works (backward compat)', async () => {
// Existing call sites (and the businessDocs regression test) pass
// a boolean for `includeArchived`. Refactor must not break them.
seedFile('events/archived/E3/legacy.jpg');
const filesOff = await backupService.getFilesToBackup(false);
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
});
@@ -0,0 +1,188 @@
/**
* Pins the inline-DB-dump + fail-loud guard added to `runBackupInternal`.
*
* The previous behaviour was: file-backup looked up an existing dump via
* `getDatabaseBackupInfo()` and silently shipped a files-only manifest
* when none was found. Admins clicking "Run Backup Now" got an apparent
* success that omitted every customer / quote / invoice / contract row —
* the data-loss footgun that this commit closes.
*
* Five scenarios under test:
* 1. Default (inline dump enabled), dump succeeds → backup proceeds
* 2. Default, dump throws → run aborts, backup_runs row marked failed
* 3. Opt-out + recent DB dump available → backup proceeds
* 4. Opt-out + no DB dump available → fail loud
* 5. Opt-out + DB dump file is 0 bytes on disk → fail loud
*
* Mocking strategy: the underlying `databaseBackupService.backup()` and
* the local-destination writer are stubbed so the test exercises just
* the new guard logic without depending on `pg_dump` / `sqlite3` CLI
* binaries being available in the test environment.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Set up mocks BEFORE bootCrmDb so backupService picks them up at require time.
const mockBackupFn = jest.fn();
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: { backup: mockBackupFn },
startScheduledBackups: jest.fn(),
stopScheduledBackups: jest.fn(),
DatabaseBackupService: class {},
}));
jest.setTimeout(30000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let dumpFileAbs;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
// Seed backup destination settings so the run can proceed past the
// "destination not configured" guard.
const dest = path.join(storagePath, 'backups');
fs.mkdirSync(dest, { recursive: true });
// getBackupConfigInternal filters by setting_type='backup', so the
// tests have to seed with that type or the resolver returns
// `{ ... }` with the keys missing — runBackup then sees
// `backup_destination_type === undefined` and bails before our
// new guard runs.
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(dest), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
]).onConflict('setting_key').merge();
// Pre-create a dump file that getDatabaseBackupInfo can resolve to.
// Reused/mutated per-test via the database_backup_runs seed below.
dumpFileAbs = path.join(storagePath, 'backups', 'fake-dump.sql.gz');
fs.writeFileSync(dumpFileAbs, 'pretend this is a pg_dump'.repeat(100));
// Neutralise the file-scan step: we don't care which files would
// be backed up, just whether the run reaches that stage at all.
backupService.getFilesToBackup = jest.fn(async () => []);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockBackupFn.mockReset();
// Default to "dump produced this file with this size" — the per-test
// setup overrides as needed.
mockBackupFn.mockResolvedValue({
success: true,
path: dumpFileAbs,
size: fs.statSync(dumpFileAbs).size,
duration: 1,
checksum: 'abc',
});
// Re-seed the database_backup_runs row that getDatabaseBackupInfo
// resolves against (its query is `status='completed'` + most recent).
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: dumpFileAbs,
file_size_bytes: fs.statSync(dumpFileAbs).size,
destination_path: dumpFileAbs,
});
});
it('default behaviour: inline dump runs, then file backup proceeds', async () => {
// Inline-dump setting is unset (undefined) — default is ON.
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
await backupService.runBackup(true);
expect(mockBackupFn).toHaveBeenCalledTimes(1);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
expect(run.error_message).toBeNull();
});
it('aborts the run when the inline dump throws', async () => {
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
mockBackupFn.mockRejectedValueOnce(new Error('pg_dump segfaulted'));
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/pg_dump segfaulted/);
});
it('opt-out: skips inline dump but proceeds when a recent dump exists', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
await backupService.runBackup(true);
expect(mockBackupFn).not.toHaveBeenCalled();
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
});
it('opt-out + no recent dump: fails loud with a clear error', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Wipe the dump row so getDatabaseBackupInfo returns backupFile=null.
await db('database_backup_runs').del();
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/No database backup available/);
});
it('opt-out + 0-byte dump file: fails loud', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const emptyDump = path.join(storagePath, 'backups', 'empty-dump.sql.gz');
fs.writeFileSync(emptyDump, '');
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: emptyDump,
file_size_bytes: 0,
destination_path: emptyDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/is empty/);
});
});
@@ -0,0 +1,180 @@
/**
* Per-Stage-B-path tally — Tier 3 of tonight's backup hardening.
*
* Pins the new `computePerPathStats` logic that the Backup History
* "Content Backed Up" pane reads via `backup_runs.statistics.per_path`.
*
* Three scenarios:
* 1. Single file under one path — straightforward attribution
* 2. Multiple paths with overlapping prefixes — longest-prefix wins
* (e.g. `events/active/E1/x.jpg` should attribute to
* `events/active`, not `events`)
* 3. File outside any configured path — silently dropped, doesn't
* throw or contaminate other buckets
*
* Tests exercise the EXPORTED side: write a backup_runs row via the
* service entry point and assert the statistics JSON shape. We don't
* stub `computePerPathStats` directly — the integration view is what
* the frontend actually consumes.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkFile(rel, content = 'x'.repeat(100)) {
const abs = path.join(storagePath, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Clean slate of any artefacts from prior tests
await db('backup_runs').del();
await db('app_settings').where('setting_type', 'backup').del();
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(path.join(storagePath, 'destination')), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
{ setting_key: 'backup_include_archived', setting_value: JSON.stringify(true), setting_type: 'backup' },
]).onConflict('setting_key').merge();
fs.mkdirSync(path.join(storagePath, 'destination'), { recursive: true });
// Restore canonical backup_paths from migration 109
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').del();
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
// Wipe leftover files between tests
for (const dir of ['events', 'business-docs', 'thumbnails', 'previews', 'heroes', 'uploads']) {
const p = path.join(storagePath, dir);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
});
it('attributes files to their owning backup_paths row', async () => {
mkFile('events/active/E1/photo-a.jpg', 'X'.repeat(1000));
mkFile('events/active/E1/photo-b.jpg', 'X'.repeat(2000));
mkFile('business-docs/quote/2026/Q-1.pdf', 'X'.repeat(500));
mkFile('thumbnails/E1/photo-a.jpg', 'X'.repeat(50));
// Disable the inline DB dump so we don't need pg_dump in tests;
// the file walker is what produces per_path.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Seed a fake DB-backup row so the fail-loud guard is satisfied.
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
expect(statsRaw.per_path).toBeDefined();
// events/active should have 2 files (3000 bytes)
expect(statsRaw.per_path['events/active']).toEqual({ count: 2, size: 3000 });
// business-docs should have 1 file (500 bytes)
expect(statsRaw.per_path['business-docs']).toEqual({ count: 1, size: 500 });
// thumbnails should have 1 file (50 bytes)
expect(statsRaw.per_path['thumbnails']).toEqual({ count: 1, size: 50 });
// No spurious buckets for paths that had nothing
expect(statsRaw.per_path['previews']).toBeUndefined();
expect(statsRaw.per_path['heroes']).toBeUndefined();
});
it('archived path attributed separately from active when both have files', async () => {
mkFile('events/active/E1/active.jpg', 'X'.repeat(100));
mkFile('events/archived/E2/archived.jpg', 'X'.repeat(200));
// backup_include_archived already set true in beforeEach so the
// archived walker fires; same opt-out for inline DB dump.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
// events/active and events/archived attribute separately —
// longest-prefix match prevents `events/active/...` from claiming
// an `events/archived/...` file or vice versa.
expect(statsRaw.per_path['events/active']).toEqual({ count: 1, size: 100 });
expect(statsRaw.per_path['events/archived']).toEqual({ count: 1, size: 200 });
});
});
// NOTE on walker duplication
//
// If two `backup_paths` rows overlap (e.g. one row at `events` AND
// another at `events/active`), the walker scans the same files twice
// — once via each path. Per-path stats then attribute the file to the
// longest-prefix-matching path BOTH times, producing inflated counts.
//
// The canonical seed in migration 109 contains no overlapping pairs,
// so this isn't exercised in practice. But an admin who hand-adds a
// broad row that overlaps an existing nested one will see double
// counts in their next backup's statistics + the destination will
// receive duplicate copies (wasting space). Worth flagging if anyone
// reports it — the fix is to de-dupe `files` in
// `getFilesToBackupInternal` before returning, OR to skip walking a
// path if a longer one has already covered it.
@@ -0,0 +1,143 @@
/**
* Smoke tests for backupService's config resolution + file-collection
* and manifest validation paths — safety net ahead of the god-file
* decomposition.
*
* Uses the same real-SQLite harness as
* backupService.configurableWalker.test.js (bootCrmDb + a temp
* STORAGE_PATH) rather than the broken deep-mock approach in
* backupService.enhanced.test.js.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let backupManifest;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
backupManifest = require('../../src/services/backupManifest');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('app_settings').del();
// Reset the storage tree so each test starts from a pristine walk.
await fs.promises.rm(storagePath, { recursive: true, force: true });
await fs.promises.mkdir(storagePath, { recursive: true });
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return abs;
}
async function insertBackupSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: value,
setting_type: 'backup',
});
}
describe('getBackupConfig', () => {
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
await insertBackupSetting('backup_enabled', 'true');
await insertBackupSetting('backup_include_archived', 'false');
await insertBackupSetting('backup_retention_days', '30');
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
// Non-backup settings must not leak into the backup config.
await db('app_settings').insert({
setting_key: 'general_site_name',
setting_value: 'PicPeak',
setting_type: 'general',
});
const config = await backupService.getBackupConfig();
expect(config.backup_enabled).toBe(true);
expect(config.backup_include_archived).toBe(false);
expect(config.backup_retention_days).toBe(30);
expect(config.backup_destination_path).toBe('/backups/picpeak');
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
expect(config).not.toHaveProperty('general_site_name');
// Raw (unparsed) values are preserved on the non-enumerable __raw.
expect(String(config.__raw.backup_retention_days)).toBe('30');
});
it('returns an empty config object (not null) when nothing is configured', async () => {
const config = await backupService.getBackupConfig();
expect(config).not.toBeNull();
expect(Object.keys(config)).toHaveLength(0);
});
});
describe('getFilesToBackup', () => {
it('returns an empty list on a pristine storage tree', async () => {
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
expect(files).toEqual([]);
});
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
const content = 'not really a jpeg';
const abs = seedFile('events/active/E9/pic.jpg', content);
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
expect(entry).toBeDefined();
expect(entry.path).toBe(abs);
expect(entry.size).toBe(Buffer.byteLength(content));
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
// realm under Jest and fails the cross-realm instanceof check.
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
});
});
describe('validateBackupManifest', () => {
it('round-trips a generated manifest as valid', async () => {
seedFile('events/active/E1/a.jpg', 'aaa');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const manifest = await backupManifest.generateManifest({
backupType: 'full',
backupPath: '/backup/run-1',
files,
});
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
await backupManifest.saveManifest(manifest, manifestPath, 'json');
const result = await backupService.validateBackupManifest(manifestPath);
expect(result.valid).toBe(true);
expect(result.manifest.backup.type).toBe('full');
expect(result.manifest.files.count).toBe(files.length);
expect(result.manifest.verification.total_checksum).toBeTruthy();
});
it('flags a manifest missing required sections as invalid', async () => {
const badPath = path.join(storagePath, 'manifest-broken.json');
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
const result = await backupService.validateBackupManifest(badPath);
expect(result.valid).toBe(false);
expect(result.error).toMatch(/Missing required section/);
});
});
});
@@ -0,0 +1,170 @@
/**
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
* never auto-sends them before the workflow's review gate + explicit
* send_document.
*/
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
quoteService = require('../../src/services/quoteService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function acceptedQuote() {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
}
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
});
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled');
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
});
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const ev = await db('events').where({ id: res.eventId }).first();
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
// Every invoice the event scheduled is held (no auto-send before the gate).
const invs = await db('invoices').whereIn('id', res.invoiceIds);
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
// again for it (the flow's prepare_invoice adopts these ids instead).
const q = await db('quotes').where({ id: quoteId }).first();
expect(q.converted_event_id).toBe(res.eventId);
});
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
// Reproduces the booking_invoice_only flow on a quote with no explicit
// payment timing: the default installment is after_delivery, which would
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
const dealUuid = crypto.randomUUID();
const [quoteId] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
// 100% after_delivery installment.
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
});
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
const mk = async (lockOffsetMs) => {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF', issue_date: '2026-01-01',
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
responded_at: new Date().toISOString(),
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
accepted_at: new Date().toISOString(),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
};
const openId = await mk(15 * 60 * 1000); // still inside the window
const lockedId = await mk(-60 * 1000); // window already closed
const emitted = await quoteService.finalizeQuoteResponses();
expect(emitted).toBeGreaterThanOrEqual(1);
const open = await db('quotes').where({ id: openId }).first();
const locked = await db('quotes').where({ id: lockedId }).first();
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
const again = await db('quotes').where({ id: lockedId })
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
expect(again).toBe(0);
});
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(res.invoiceIds).toEqual([]);
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
});
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
const quoteId = await acceptedQuote();
const newId = await quoteService.duplicateQuote(quoteId, adminId);
expect(newId).toBeGreaterThanOrEqual(1);
expect(newId).not.toBe(quoteId);
const q = await db('quotes').where({ id: newId }).first();
expect(q.status).toBe('draft');
});
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
expect(typeof registry.getAction(a)).toBe('function');
}
});
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
const contractService = require('../../src/services/contractService');
const quoteId = await acceptedQuote();
const res = await contractService.createFromQuote(quoteId, adminId);
expect(res.contractId).toBeGreaterThanOrEqual(1);
expect(res.alreadyConverted).toBe(false);
const c = await db('contracts').where({ id: res.contractId }).first();
expect(c).toBeTruthy();
});
});
@@ -0,0 +1,116 @@
/**
* Schema-shape regression net for the CRM consolidated migration.
*
* Pins the table/column layout that the route + service layer expect
* after `migrations/core/107_crm_consolidated.js` runs. The schema-
* drift workflow (#530) catches Postgres-only FK ordering bugs (the
* forward-reference deferral added in this PR), but it doesn't notice
* if a future edit silently drops a column the service code reads —
* SQLite would just return undefined and the broken behavior would
* land on beta.
*
* Touches the lineage chain (deal_uuid + back-pointer FKs) explicitly
* so a rename or removal there fails the test instead of silently
* breaking the lineage card.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('CRM schema after core migrations', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('table layout', () => {
const expectedTables = [
'admin_users', 'customer_accounts', 'business_profile', 'business_bank_accounts',
'events', 'document_sequences',
'quotes', 'quote_line_items', 'quote_line_item_presets', 'quote_action_tokens',
'contracts', 'contract_blocks', 'contract_block_inclusions', 'contract_action_tokens',
'invoices', 'invoice_line_items', 'invoice_payment_log', 'invoice_payment_check_tokens',
'customer_hour_entries',
'payment_term_templates', 'payment_net_days_templates', 'payment_timing_templates',
'event_payment_plans',
];
it.each(expectedTables)('has table %s', async (table) => {
expect(await db.schema.hasTable(table)).toBe(true);
});
});
describe('deal_uuid lineage columns', () => {
// Every document in one engagement shares a deal_uuid — the
// lineage card joins on it. Drop the column anywhere in the chain
// and the card silently returns partial data.
it.each(['quotes', 'contracts', 'invoices'])(
'%s has deal_uuid column',
async (table) => {
expect(await db.schema.hasColumn(table, 'deal_uuid')).toBe(true);
}
);
// The back-pointer FKs were the source of the schema-drift bug
// we fixed in this PR (forward references). Pin them.
it('quotes has converted_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('quotes', 'converted_contract_id')).toBe(true);
});
it('invoices has source_contract_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_contract_id')).toBe(true);
});
it('invoices has source_quote_id back-pointer', async () => {
expect(await db.schema.hasColumn('invoices', 'source_quote_id')).toBe(true);
});
});
describe('Storno discriminator columns', () => {
// kind='storno' + cancels_invoice_id + negative totals are the
// shape every aggregate filter relies on (feedback_storno_filter_
// everywhere). Pin the columns so a rename doesn't silently break
// every revenue report.
it('invoices has kind discriminator', async () => {
expect(await db.schema.hasColumn('invoices', 'kind')).toBe(true);
});
it('invoices has cancels_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'cancels_invoice_id')).toBe(true);
});
it('invoices has replaces_invoice_id self-ref', async () => {
expect(await db.schema.hasColumn('invoices', 'replaces_invoice_id')).toBe(true);
});
});
describe('Event time columns (migration 137)', () => {
// The admin calendar reads these to render timed vs. full-day
// tiles. Per the feedback_migration_preserve_visuals rule, the
// default has to be `is_full_day=true` so existing rows keep
// their pre-migration visual.
it('events has event_time_start', async () => {
expect(await db.schema.hasColumn('events', 'event_time_start')).toBe(true);
});
it('events has event_time_end', async () => {
expect(await db.schema.hasColumn('events', 'event_time_end')).toBe(true);
});
it('events has is_full_day', async () => {
expect(await db.schema.hasColumn('events', 'is_full_day')).toBe(true);
});
});
describe('seed paths', () => {
it('admin + customer seed inserts cleanly', async () => {
const { adminId, customerId } = await seedMinimal(db);
expect(adminId).toBeTruthy();
expect(customerId).toBeTruthy();
const admin = await db('admin_users').where({ id: adminId }).first();
const customer = await db('customer_accounts').where({ id: customerId }).first();
expect(admin.email).toBe('tester@example.com');
expect(customer.email).toBe('customer@example.com');
});
});
});
@@ -0,0 +1,79 @@
/**
* Negative line items (Rabatt / manual discount lines) are accepted
* end-to-end as long as the resulting total stays ≥ 0. When the
* discount would drive the total negative, the service rejects with
* a clear, code-tagged error so the admin is steered to Storno for
* credit-note workflows.
*
* Touches the actual createInvoice / createQuote service paths so a
* future change to either computeTotals or the guard fires this test.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService,
// nodemailer, etc.) on first use; the global 5 s per-test budget is
// too tight for that. Bump it for this file only.
jest.setTimeout(30000);
describe('discount line items (negative unit_price_minor)', () => {
let db;
let cleanup;
let adminId;
let customerId;
let invoiceService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
invoiceService = require('../../src/services/invoiceService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Quote-side coverage of the symmetric validator + guard is
// deliberately omitted: createQuote's init path takes ~30 s under
// this harness (something in pdfService / emailProcessor cold-
// require), which would push the suite well past CI's per-test
// budget. The shape of the guard is identical to the invoice one
// covered below; a future change to extract the slow init or to
// stub it for tests should re-enable a parallel quote test.
describe('invoices', () => {
it('accepts a negative-price line and computes the net correctly', async () => {
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 20000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Treuerabatt', unit_price_minor: -5000, discount_percent: 0 },
],
}, adminId);
expect(Array.isArray(invoiceIds)).toBe(true);
expect(invoiceIds.length).toBe(1);
const row = await db('invoices').where({ id: invoiceIds[0] }).first();
expect(row.net_amount_minor).toBe(15000);
expect(row.total_amount_minor).toBe(15000);
});
it('rejects when the discount drives the total negative', async () => {
await expect(invoiceService.createInvoice({
customerAccountId: customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [
{ position: 1, quantity: 1, description: 'Photo service', unit_price_minor: 10000, discount_percent: 0 },
{ position: 2, quantity: 1, description: 'Übergroßer Rabatt', unit_price_minor: -50000, discount_percent: 0 },
],
}, adminId)).rejects.toMatchObject({
code: 'INVOICE_TOTAL_NEGATIVE',
statusCode: 400,
});
});
});
});
@@ -0,0 +1,98 @@
/**
* Boot-time email-template self-heal:
* 1. Seeds the CRM / contract / event-reminder templates on an
* install that's never had them before.
* 2. Recovers email_queue rows that previously exhausted their
* retries because their template was missing.
*
* The failure that triggered this fix (2026-05-27) had Ralf's beta
* box failing every `quote_sent` / `invoice_sent` send for ~14h
* because crmEmailTemplates.ensureCrmEmailTemplatesSeeded was
* defined but never called. After 3 retries the rows sat in
* status='pending' forever; nothing in the admin UI signalled the
* problem. Both halves of that regression are covered here.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('email template self-heal at boot', () => {
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('seeds crm/contract/event-reminder templates and recovers stuck queue rows', async () => {
// Sanity: a fresh CRM-migrated DB does NOT carry CRM templates —
// 107_crm_consolidated documents the deliberate split (templates
// are self-healed at runtime, not inserted by the migration).
const before = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(before).toEqual([]);
// Seed a stuck queue row that mirrors what we found on Ralf's box:
// quote_sent send attempted 3 times, each time failed because the
// template didn't exist, queue processor gave up.
const queueRowIds = await db('email_queue').insert({
recipient_email: 'customer@example.com',
email_type: 'quote_sent',
email_data: JSON.stringify({ quote_number: 'Q-2026-0001' }),
status: 'pending',
retry_count: 3,
error_message: "Email template 'quote_sent' not found",
created_at: new Date(),
}).returning('id');
const queueRowId = typeof queueRowIds[0] === 'object' ? queueRowIds[0].id : queueRowIds[0];
// Also seed an UNRELATED stuck row (different template, NOT one
// we're going to insert) to confirm the recovery is targeted —
// it must not blanket-reset every retry-exhausted row.
const unrelatedIds = await db('email_queue').insert({
recipient_email: 'someone@example.com',
email_type: 'some_other_template',
email_data: JSON.stringify({}),
status: 'pending',
retry_count: 3,
error_message: 'SMTP timeout',
created_at: new Date(),
}).returning('id');
const unrelatedId = typeof unrelatedIds[0] === 'object' ? unrelatedIds[0].id : unrelatedIds[0];
// The seeders use module-level caches (`_seeded = true`). When
// jest runs this test in isolation that cache starts fresh; in
// the full suite no other test currently calls these seeders, so
// the first call here also runs the real work. Reset the cache
// defensively in case a future test changes that.
jest.resetModules();
const { seedEmailTemplatesAndRecoverQueue } = require('../../src/services/_emailTemplateBoot');
const result = await seedEmailTemplatesAndRecoverQueue(db, null);
// Templates landed.
expect(result.seeded).toEqual(expect.arrayContaining([
'quote_sent', 'invoice_sent', 'storno_issued',
]));
const after = await db('email_templates')
.whereIn('template_key', ['quote_sent', 'invoice_sent', 'storno_issued'])
.pluck('template_key');
expect(after.sort()).toEqual(['invoice_sent', 'quote_sent', 'storno_issued']);
// Stuck quote_sent row was recovered.
expect(result.recovered).toBeGreaterThanOrEqual(1);
const recoveredRow = await db('email_queue').where({ id: queueRowId }).first();
expect(recoveredRow.retry_count).toBe(0);
expect(recoveredRow.error_message).toBeNull();
expect(recoveredRow.status).toBe('pending'); // ready for the next tick
// Unrelated stuck row was NOT touched.
const unrelatedRow = await db('email_queue').where({ id: unrelatedId }).first();
expect(unrelatedRow.retry_count).toBe(3);
expect(unrelatedRow.error_message).toBe('SMTP timeout');
});
});
@@ -0,0 +1,64 @@
/**
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
* old slug, so a rename behaves like a rename rather than silently detaching
* existing events/quotes and orphaning the per-type pre-event reminder template.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(30000);
describe('event type slug rename cascade', () => {
let db;
let cleanup;
let customerId;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-points events + quotes + the reminder template from old slug to new', async () => {
// A non-system event type with slug 'party'.
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
// An authored per-type reminder template + an event + a quote, all on 'party'.
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
await db('events').insert({
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
event_name: 'A party', event_date: '2026-09-01',
});
await db('quotes').insert({
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
});
// Rename the slug.
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
// Event + quote follow the rename.
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
// The authored reminder template moved (subject/body preserved), old key gone.
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
expect(moved).toBeTruthy();
expect(moved.subject_en).toBe('Party reminder');
});
it('does not clobber an existing template for the new slug', async () => {
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
// Target already existed → left intact; source not force-merged over it.
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
.toBe('existing soiree');
});
});
@@ -0,0 +1,231 @@
/**
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
*
* Verifies the contract the public route is expected to honour:
* - Browser UA → 302 to target_path
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
* - Unknown slug → 404 Not Found
* - Hit count increments after successful resolutions (both shapes)
*
* Mirrors the production server.js wiring but doesn't load the whole
* server — the surrounding middleware (CORS, helmet, rate limiters)
* isn't part of this route's contract.
*/
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Persist a business_profile + business_name so buildOgMetadata's
// settings-based fields populate consistently.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
service = require('../../src/services/galleryShortUrlService');
const {
isSocialCrawler, buildOgMetadata, renderOgHtml,
} = require('../../src/services/galleryOgService');
app = express();
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await service.findByShortSlug(req.params.shortSlug);
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
if (isSocialCrawler(req.get('user-agent'))) {
const event = await db('events').where({ id: row.event_id }).first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
service.recordHit(row.id).catch(() => {});
return;
}
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
service.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
return res.status(500).type('text/plain').send(err.message);
}
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [eventId] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Test Event',
event_date: '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const row = await service.createShortUrl({
eventId, customSlug: shortSlug,
});
return { eventId, shortUrl: row };
}
// User-agent strings the production `isSocialCrawler` helper matches.
// Snapshot known-true samples here so the test stays in sync if the
// helper's allowlist evolves.
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
it('redirects to the snapshotted target_path with a 302', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'browser-redirect', shortSlug: 'go-here',
});
const res = await request(app)
.get('/s/go-here')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(shortUrl.target_path);
expect(res.headers.location).toMatch(/^\/gallery\//);
});
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
await seedEventAndShortUrl({
slug: 'hit-browser', shortSlug: 'hit-from-browser',
});
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-browser');
expect(row.hit_count).toBe(1);
expect(row.last_hit_at).toBeTruthy();
});
});
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
it('returns 200 with OG HTML for WhatsApp UA', async () => {
await seedEventAndShortUrl({
slug: 'whatsapp-og', shortSlug: 'wa-preview',
});
const res = await request(app)
.get('/s/wa-preview')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.text).toContain('<meta');
expect(res.text).toMatch(/og:title/);
expect(res.text).toMatch(/og:url/);
});
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
await seedEventAndShortUrl({
slug: 'canonical-test', shortSlug: 'canonical-short',
});
const res = await request(app)
.get('/s/canonical-short')
.set('User-Agent', BOT_UA_FACEBOOK);
expect(res.status).toBe(200);
// The og:url meta tag must contain the short-URL path, not the
// /gallery/<slug> path — this is the cache-key invariant from #699.
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
expect(res.text).not.toMatch(
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
);
});
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
await seedEventAndShortUrl({
slug: 'cache-header', shortSlug: 'cache-test',
});
const res = await request(app)
.get('/s/cache-test')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.headers['cache-control']).toMatch(/public/);
expect(res.headers['cache-control']).toMatch(/max-age=300/);
});
it('increments hit_count on a crawler hit as well', async () => {
await seedEventAndShortUrl({
slug: 'hit-bot', shortSlug: 'hit-from-bot',
});
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-bot');
expect(row.hit_count).toBe(1);
});
});
describe('GET /s/:shortSlug — error states', () => {
it('404 for an unknown slug', async () => {
const res = await request(app)
.get('/s/never-existed')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'gone-test', shortSlug: 'gone-slug',
});
await service.softDelete(shortUrl.id, null);
const res = await request(app)
.get('/s/gone-slug')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(410);
});
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
const { eventId } = await seedEventAndShortUrl({
slug: 'orphan-test', shortSlug: 'orphan-slug',
});
// Hard-delete the event row (FK CASCADE would normally clean up the
// short URL too — but if CASCADE didn't fire for whatever reason
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
// resolver should still degrade safely).
// SQLite's foreign_keys pragma is OFF by default; the migration
// doesn't toggle it, so this delete leaves the short URL row.
await db('events').where({ id: eventId }).delete();
const res = await request(app)
.get('/s/orphan-slug')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(410);
});
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
const res = await request(app)
.get('/s/UPPER_CASE')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
});
describe('Regression — existing URL paths must still respond the same', () => {
// The /s/* namespace is additive: it must NOT shadow /gallery/*
// or any of the OG routes. We don't load the whole app here, but we
// can at least pin that the route param doesn't accept slashes —
// i.e. /s/foo/bar must NOT be matched by our handler.
it('the /s/:shortSlug route does not match nested paths', async () => {
const res = await request(app)
.get('/s/foo/bar')
.set('User-Agent', BROWSER_UA);
// Express returns its default 404 when no route matches the path.
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,282 @@
/**
* Integration tests for the branded short-URL service (#699).
*
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
* + recordHit against a real SQLite DB, including the contracts that
* matter for production correctness:
*
* - Custom slug + collision detection (409 with `suggested`)
* - Auto-generated slug from event slug + year
* - Soft-delete preserves the row (admin can audit)
* - target_path snapshots at create time (toggling the global
* "Use short gallery URLs" setting later doesn't change existing
* short URLs — backward-compat invariant from #699)
* - hit_count increments idempotently
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
*
* Boots one DB for the whole file (cheap on SQLite); each test seeds
* its own event row to keep scope clean.
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let adminId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Minimal admin for created_by audit.
const adminInsert = await db('admin_users').insert({
username: 'shorturl-test',
email: 'shorturl@example.com',
password_hash: 'x',
must_change_password: false,
created_at: new Date(),
}).returning('id');
adminId = adminInsert[0]?.id ?? adminInsert[0];
service = require('../../src/services/galleryShortUrlService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
// Each test seeds a fresh event so collisions / counter state don't leak.
async function seedEvent(overrides = {}) {
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [id] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: overrides.event_name || 'Test Wedding',
event_date: overrides.event_date || '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const event = await db('events').where({ id }).first();
return event;
}
describe('createShortUrl — custom slug', () => {
it('creates with a custom slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-1' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'sofia-graduation-1',
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-1');
expect(row.target_path).toBe(`/gallery/${event.slug}`);
expect(row.event_id).toBe(event.id);
expect(row.hit_count).toBe(0);
});
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-2' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'Sofia-GraduAtion-2', // mixed case
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-2');
});
it('rejects an invalid slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'invalid-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'invalid slug with spaces',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a reserved slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'reserved-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'admin',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
const event1 = await seedEvent({ slug: 'dup-test-1' });
const event2 = await seedEvent({ slug: 'dup-test-2' });
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
await expect(service.createShortUrl({
eventId: event2.id, customSlug: 'collide-me',
})).rejects.toMatchObject({
code: 'SLUG_TAKEN',
suggested: expect.any(String),
});
});
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
await expect(service.createShortUrl({
eventId: 9999999, customSlug: 'no-event',
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
});
});
describe('createShortUrl — auto-generated slug', () => {
it('uses event slug + year when no custom slug provided', async () => {
const event = await seedEvent({
slug: 'autogen-wedding', event_date: '2026-06-05',
});
const row = await service.createShortUrl({
eventId: event.id,
createdBy: adminId,
});
// First-choice candidate is just the slug; takes that.
expect(row.short_slug).toBe('autogen-wedding');
});
it('falls back to slug-year when the bare slug is already taken', async () => {
// Both events SHARE the same canonical slug so the first-choice
// bare-slug candidate is burned, forcing autoGen to try the
// year-suffixed variant.
const event1 = await seedEvent({
slug: 'collide-base', event_date: '2026-07-01',
});
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base',
});
const event2 = await seedEvent({
slug: 'collide-base-2', event_date: '2026-07-01',
});
// Force the bare candidate of event2 to also collide by burning it.
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base-2',
});
const row = await service.createShortUrl({
eventId: event2.id, // No custom — auto-gen from event2.slug
});
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
expect(row.short_slug).toBe('collide-base-2-2026');
});
});
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
const event = await seedEvent({ slug: 'snapshot-off' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-off',
});
expect(row.target_path).toBe(`/gallery/${event.slug}`);
});
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
// Persist the setting.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
try {
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-on',
});
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
// CRITICAL backward-compat invariant: now flip the setting OFF.
// Existing short URLs must still resolve to the same target_path
// they were created with — operator's existing share links don't
// silently change behaviour.
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
const refetched = await service.findByShortSlug('snap-on');
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
} finally {
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
}
});
});
describe('findByShortSlug + listForEvent', () => {
it('returns null for an unknown slug', async () => {
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
});
it('returns null for a malformed slug (no DB hit)', async () => {
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
expect(await service.findByShortSlug('with spaces')).toBeNull();
expect(await service.findByShortSlug('')).toBeNull();
});
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
const event = await seedEvent({ slug: 'softdel-find' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'find-deleted',
});
await service.softDelete(created.id, adminId);
const fetched = await service.findByShortSlug('find-deleted');
expect(fetched).not.toBeNull();
expect(fetched.deleted_at).toBeTruthy();
});
it('listForEvent excludes soft-deleted rows', async () => {
const event = await seedEvent({ slug: 'list-test' });
const live = await service.createShortUrl({
eventId: event.id, customSlug: 'list-live',
});
const deleted = await service.createShortUrl({
eventId: event.id, customSlug: 'list-deleted',
});
await service.softDelete(deleted.id, adminId);
const list = await service.listForEvent(event.id);
const ids = list.map((r) => r.id);
expect(ids).toContain(live.id);
expect(ids).not.toContain(deleted.id);
});
});
describe('softDelete', () => {
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
const event = await seedEvent({ slug: 'softdel-idem' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'idem-delete',
});
expect(await service.softDelete(created.id, adminId)).toBe(true);
expect(await service.softDelete(created.id, adminId)).toBe(false);
});
it('returns false for an unknown id (caller maps to 404)', async () => {
expect(await service.softDelete(9999999, adminId)).toBe(false);
});
});
describe('createShortUrl after soft-delete — slug rotation', () => {
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
const event = await seedEvent({ slug: 'rotate' });
const first = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
await service.softDelete(first.id, adminId);
// The slug is now reclaimable for a fresh row.
const second = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
expect(second.id).not.toBe(first.id);
expect(second.short_slug).toBe('rotate-me');
});
});
describe('recordHit', () => {
it('increments hit_count + stamps last_hit_at', async () => {
const event = await seedEvent({ slug: 'hit-counter' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'count-me',
});
await service.recordHit(row.id);
await service.recordHit(row.id);
const fetched = await service.findByShortSlug('count-me');
expect(fetched.hit_count).toBe(2);
expect(fetched.last_hit_at).toBeTruthy();
});
it('is fire-and-forget — invalid id does not throw', async () => {
await expect(service.recordHit(9999999)).resolves.not.toThrow();
});
});
@@ -0,0 +1,230 @@
/**
* Test harness for CRM integration tests.
*
* Boots a temp-SQLite database, runs every `migrations/core/*.up()`
* directly (bypassing knex's Migrator — its exclusive write lock
* deadlocks 001_init's nested `initializeDatabase()` call), and
* exposes a small helper for seeding the minimal row set that the
* quote/contract/invoice services need to operate.
*
* Usage:
*
* const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
*
* beforeAll(async () => {
* ({ db, cleanup } = await bootCrmDb());
* ({ adminId, customerId } = await seedMinimal(db));
* });
* afterAll(async () => { await cleanup(); });
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const bcrypt = require('bcrypt');
async function runCoreMigrations(db) {
await db.schema.createTable('migrations', (t) => {
t.increments('id').primary();
t.string('filename').unique().notNullable();
t.timestamp('applied_at').defaultTo(db.fn.now());
});
const coreDir = path.resolve(__dirname, '..', '..', '..', 'migrations', 'core');
const files = (await fs.promises.readdir(coreDir))
.filter((f) => f.endsWith('.js'))
.sort();
for (const f of files) {
const mod = require(path.join(coreDir, f));
if (typeof mod.up === 'function') {
await mod.up(db);
}
await db('migrations').insert({ filename: f });
}
}
/**
* Boot a clean test DB. Returns { db, cleanup, tmpDir }.
* Caller must invoke cleanup() in afterAll to release the SQLite file
* and the temp directory.
*/
async function bootCrmDb() {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-crm-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'crm.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
// No jest.resetModules() — every service the test later requires
// must share THIS db instance. Two module copies on one SQLite file
// each open their own knex pool and the SQLite write lock deadlocks
// the second one acquiring a connection. Caller is responsible for
// setting TEST_DATABASE_PATH before the first require of db.js
// (which knexfile reads at module-init time); bootCrmDb only works
// when invoked before any service import.
const { db } = require('../../../src/database/db');
await runCoreMigrations(db);
return {
db,
tmpDir,
cleanup: async () => {
try { await db.destroy(); } catch (_) {}
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch (_) {}
},
};
}
/**
* Seed the minimal row set that quote/contract/invoice services
* dereference on creation: an admin user, an active customer, a
* business_profile row, and the app_settings keys the services read.
*
* Returns the ids the caller will pass into service calls.
*/
async function seedMinimal(db) {
const passwordHash = await bcrypt.hash('test-pass', 4); // low rounds = fast
const adminInsert = await db('admin_users').insert({
username: 'tester', email: 'tester@example.com',
password_hash: passwordHash, must_change_password: false,
created_at: new Date(),
}).returning('id');
const adminId = adminInsert[0]?.id ?? adminInsert[0];
// business_profile is a singleton; the row is seeded by migration 107
// for fresh installs. Defensive: insert if missing.
const profile = await db('business_profile').first();
if (!profile) {
await db('business_profile').insert({
legal_name: 'Test Studio',
default_currency: 'CHF',
default_locale: 'de',
});
}
const customerInsert = await db('customer_accounts').insert({
email: 'customer@example.com',
display_name: 'Test Customer',
password_hash: passwordHash,
preferred_language: 'de',
is_active: 1,
created_at: new Date(),
}).returning('id');
const customerId = customerInsert[0]?.id ?? customerInsert[0];
return { adminId, customerId };
}
// ---------------------------------------------------------------------
// Route-test helpers (#570) — building blocks for the CRM HTTP layer
// tests. Kept here so every supertest suite shares the same minting +
// app-wiring shape and a refactor lands in one place.
// ---------------------------------------------------------------------
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const express = require('express');
const cookieParser = require('cookie-parser');
/**
* Promote a seeded admin into a role (default `super_admin`) so
* `requirePermission(...)` checks pass. seedMinimal creates an admin
* without a role — that's good for negative tests (expect 403) but
* happy-path tests need the role assignment.
*
* Returns the role id the admin was assigned to.
*/
async function assignAdminRole(db, adminId, roleName = 'super_admin') {
const role = await db('roles').where({ name: roleName }).first();
if (!role) {
throw new Error(`Role '${roleName}' not seeded — check the test DB`);
}
await db('admin_users').where({ id: adminId }).update({ role_id: role.id });
return role.id;
}
/**
* Mint an admin JWT in the same shape adminAuth middleware expects.
* The tests inject this via `Authorization: Bearer <token>`.
*/
function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
return jwt.sign(
{ id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims },
process.env.JWT_SECRET,
{ expiresIn, issuer: 'picpeak-auth' }
);
}
/**
* Insert a row into one of the public-token tables for testing the
* loadActionToken guard outcomes. Returns the generated 64-hex token.
*
* Usage:
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null });
*/
async function createPublicToken(db, tableName, opts = {}) {
const token = opts.token || crypto.randomBytes(32).toString('hex');
const expiresAt = opts.expires_at === null
? null
: (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
// Serialise Date → ISO string. Bare Date objects round-tripped
// inconsistently through knex+SQLite — sometimes as epoch ms,
// sometimes via .toString() → literal "[object Object]" which then
// parses back to NaN and silently defeats the expiry guard.
const toStorable = (v) => (v instanceof Date ? v.toISOString() : v);
const row = {
...opts,
token,
expires_at: toStorable(expiresAt),
created_at: toStorable(new Date()),
};
await db(tableName).insert(row);
return token;
}
/**
* Build an Express app with the requested route file mounted. Mirrors
* the production app's middleware shape (json + cookies) but skips
* everything else (CORS, helmet, rate limiters) — route tests pin the
* handler's contract, not the surrounding cross-cutting concerns.
*
* Example:
* const app = buildRouteApp('/api/public/quotes',
* require('../../src/routes/publicQuotes'));
*/
function buildRouteApp(mount, router) {
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(mount, router);
// Catch-all error handler. Mirrors the real middleware/errorHandler:
// AppError subclasses (ValidationError, NotFoundError, etc.) use
// `.statusCode` (NOT `.status` — getting that wrong silently maps
// every 400 / 404 / 410 to 500 in tests).
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const statusCode = err.statusCode || err.status || 500;
res.status(statusCode).json({
error: err.message || 'Internal error',
code: err.code,
...(err.details ? { details: err.details } : {}),
});
});
return app;
}
module.exports = {
bootCrmDb,
seedMinimal,
assignAdminRole,
mintAdminToken,
createPublicToken,
buildRouteApp,
};
@@ -0,0 +1,211 @@
/**
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
* real SQLite schema. Covers the bits unit tests can't: the disposition state
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
* the monthly accumulator immediate-bill — i.e. that categorizeInbound /
* billPendingRebills actually mint / amend invoice rows correctly.
*
* No date-range comparisons are exercised here, so it's safe on SQLite (the
* usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
* doesn't apply to this path).
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(60000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
let cleanup;
let adminId;
let expenseService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
// second write connection deadlocks against the held write lock on
// SQLite. It's fire-and-forget audit noise, irrelevant to these
// assertions, so stub it BEFORE the services destructure it at require
// time. (Production runs Postgres, where the concurrent write is fine.)
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
async function captureDoc(overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload',
status: 'unsorted',
parse_status: 'pending',
parse_method: 'none',
supplier_name: 'ACME AG',
currency: 'CHF',
total_amount_minor: 10000,
invoice_date: '2026-06-01',
created_at: new Date(),
updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
let customerSeq = 0;
async function makeCustomer(billingCadence) {
customerSeq += 1;
const ins = await db('customer_accounts').insert({
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: billingCadence || null,
created_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.status).toBe('categorized');
expect(doc.billedInvoiceId).toBeNull();
expect(doc.customerAccountId).toBeNull();
});
it('rebill REQUIRES a customer', async () => {
const id = await captureDoc();
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
});
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc({ total_amount_minor: 10000 });
const doc = await expenseService.categorizeInbound(id, {
disposition: 'rebill', customerAccountId: customerId,
markupType: 'percent', markupPercent: 10,
}, adminId);
expect(doc.disposition).toBe('rebill');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
expect(doc.markupType).toBe('percent');
expect(Number(doc.markupPercent)).toBe(10);
});
it('passthrough never carries a markup, even if one is sent', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, {
disposition: 'durchlaufend', customerAccountId: customerId,
markupType: 'percent', markupPercent: 25, // should be ignored
}, adminId);
expect(doc.disposition).toBe('durchlaufend');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.markupType).toBe('none');
expect(doc.markupPercent).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
const customerId = await makeCustomer('monthly');
await expect(expenseService.billPendingRebills(customerId, adminId))
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
});
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
// customer's pool; monthly-customer immediate-bill onto the running draft)
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
// claims its sequence number via the global db, which DEADLOCKS against the
// held write lock on a SQLite-backed harness (a second write connection blocks
// — verified). Production runs Postgres where the concurrent write is fine, so
// this is a harness limitation, not a product bug. The line-amount math is
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
// discountLineItems.test.js. Below we test the UNWIND path against a
// hand-crafted billed state so we don't have to mint through createInvoice. ──
// Build a billed state directly: an invoice with two lines, with the inbound
// doc stamped onto the first line as a prior re-bill.
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
const invIns = await db('invoices').insert({
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
customer_account_id: customerId,
status,
scheduled_send_at: scheduledSendAt,
is_monthly_draft: isMonthlyDraft,
currency: 'CHF',
issue_date: '2026-06-01',
due_date: '2026-07-01',
vat_rate: 0,
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
vat_amount_minor: 0,
total_amount_minor: 7000,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const invoiceId = unwrapId(invIns);
const rebillLineIns = await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
}).returning('id');
const rebillLineId = unwrapId(rebillLineIns);
await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
});
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
await db('inbound_documents').where({ id }).update({
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
});
return { id, invoiceId, rebillLineId };
}
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
const customerId = await makeCustomer('per_event');
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(recat.disposition).toBe('eigener_aufwand');
expect(recat.billedInvoiceId).toBeNull();
expect(recat.customerAccountId).toBeNull();
// The re-bill line is gone; the sibling line remains and net recomputes.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
const after = await db('invoices').where({ id: invoiceId }).first();
expect(Number(after.net_amount_minor)).toBe(3000);
});
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
const customerId = await makeCustomer('per_event');
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
// Nothing was touched — the line survives.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
});
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
// passthrough → pending
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull();
// → company expense: customer cleared, still no invoice
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.customerAccountId).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
});
@@ -0,0 +1,214 @@
/**
* Install-from-backup boot hook — pins the trigger-file convention.
*
* The hook itself depends on `restoreService.restore`, which is hard
* to fully exercise in an integration test without a real PG cluster
* (sequence resync, DROP/CREATE, etc.). So we stub the actual restore
* and verify the BOOT HOOK logic:
*
* - No trigger file → no-op, ran=false
* - Empty trigger file → picks newest manifest from manifests/
* - Non-empty trigger file → uses the path inside
* - DB not empty → refuses (no restore call)
* - DB not empty + FORCE env → proceeds
* - Successful restore → deletes trigger file
* - Failed restore → leaves trigger file in place
*
* These are the surfaces an admin will hit when actually using the
* feature — the docker-compose-on-real-PG end-to-end test belongs in
* the follow-up CI work captured as task #7 earlier today.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Stub the heavy lifting so the test stays fast + portable.
const mockRestore = jest.fn();
jest.mock('../../src/services/restoreService', () => ({
restoreService: {
restore: (...args) => mockRestore(...args),
},
}));
jest.setTimeout(30000);
describe('installFromBackupBoot', () => {
let db;
let cleanup;
let storagePath;
let backupRoot;
let manifestsDir;
let tryInstallFromBackup;
let originalBackupRootEnv;
let originalForceEnv;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupRoot = path.join(storagePath, 'backup');
manifestsDir = path.join(backupRoot, 'manifests');
fs.mkdirSync(manifestsDir, { recursive: true });
originalBackupRootEnv = process.env.BACKUP_ROOT;
originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE;
process.env.BACKUP_ROOT = backupRoot;
({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot'));
}, 120000);
afterAll(async () => {
if (originalBackupRootEnv === undefined) {
delete process.env.BACKUP_ROOT;
} else {
process.env.BACKUP_ROOT = originalBackupRootEnv;
}
if (originalForceEnv === undefined) {
delete process.env.INSTALL_FROM_BACKUP_FORCE;
} else {
process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv;
}
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockRestore.mockReset();
mockRestore.mockResolvedValue({ success: true });
delete process.env.INSTALL_FROM_BACKUP_FORCE;
// Clean trigger files + manifests between tests
for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) {
const p = path.join(backupRoot, name);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
for (const f of fs.readdirSync(manifestsDir)) {
fs.unlinkSync(path.join(manifestsDir, f));
}
// Reset DB to fresh-install state
await db('events').del();
// Leave admin_users alone — fresh-install state has 1 row.
});
it('no trigger file → no-op', async () => {
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(mockRestore).not.toHaveBeenCalled();
});
it('empty trigger file picks the newest manifest from manifests/', async () => {
const older = path.join(manifestsDir, 'backup-manifest-001.json');
const newer = path.join(manifestsDir, 'backup-manifest-002.json');
fs.writeFileSync(older, '{}');
// Set the newer file's mtime slightly later so it wins the sort
const past = new Date(Date.now() - 60_000);
fs.utimesSync(older, past, past);
fs.writeFileSync(newer, '{}');
// Empty trigger
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(newer);
expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
source: 'local',
manifestPath: newer,
restoreType: 'full',
force: true,
skipPreBackup: true,
}));
});
it('non-empty trigger file uses the path inside', async () => {
const specific = path.join(manifestsDir, 'backup-manifest-specific.json');
fs.writeFileSync(specific, '{}');
// Relative to backupRoot
fs.writeFileSync(
path.join(backupRoot, 'RESTORE_ON_INSTALL'),
'manifests/backup-manifest-specific.json\n',
);
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(specific);
});
it('deletes the trigger file after a successful restore', async () => {
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
await tryInstallFromBackup(db);
expect(fs.existsSync(triggerPath)).toBe(false);
});
it('leaves the trigger file in place when restore throws', async () => {
mockRestore.mockRejectedValueOnce(new Error('restore exploded'));
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/restore exploded/);
expect(fs.existsSync(triggerPath)).toBe(true);
});
it('refuses to run when the install already has events', async () => {
// Simulate an install with existing data
await db('events').insert({
slug: 'existing-event',
event_name: 'Existing Event',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-token',
password_hash: 'dummy-hash-for-test',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/Database not empty/);
expect(mockRestore).not.toHaveBeenCalled();
// Trigger file should NOT be deleted — admin needs to fix + retry
expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true);
});
it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => {
await db('events').insert({
slug: 'existing-event-2',
event_name: 'Existing Event 2',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-2-token',
password_hash: 'dummy-hash-for-test-2',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
process.env.INSTALL_FROM_BACKUP_FORCE = 'true';
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(mockRestore).toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
/**
* Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning
* rework. Covers the fee math (flat / percent), the VAT toggle gating
* (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
* accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
* changes the issued invoice total), and the 3-reminder cap.
*
* The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and
* is verified manually; here we assert the data/immutability behaviour.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let invoiceService;
let ids;
async function setSetting(key, value) {
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting(key, JSON.stringify(value), 'crm');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
ids = await seedMinimal(db);
try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
invoiceService = require('../../src/services/invoiceService');
// Stub the (flaky) PDF render so applyReminder exercises its data path.
// eslint-disable-next-line global-require
const pdfService = require('../../src/services/pdfService');
pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
});
afterAll(async () => { await cleanup(); });
describe('dunning fee resolvers', () => {
test('flat fee, no VAT', async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const inv = { total_amount_minor: 100000 };
expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
});
test('percent fee = % of the invoice gross', async () => {
await setSetting('crm_invoices_late_fee_type', 'percent');
await setSetting('crm_invoices_late_fee_percent', 5);
expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
});
test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', true);
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
.toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
// Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
});
});
describe('applyReminder — dunning-document model', () => {
let invoiceId;
let originalTotal;
beforeAll(async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const res = await invoiceService.createInvoice({
customerAccountId: ids.customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
}, ids.adminId);
invoiceId = res.invoiceIds[0];
originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
});
test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(inv.reminder_level).toBe(2);
expect(Number(inv.late_fee_amount_minor)).toBe(2000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
});
test('level 3 accumulates the fee to 2×, total still immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(Number(inv.late_fee_amount_minor)).toBe(4000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal);
});
test('sendReminder refuses to exceed level 3', async () => {
await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow();
});
});
@@ -0,0 +1,94 @@
'use strict';
// Validates the engine-neutral .picpeak export: it must produce a real zip with
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const StreamZip = require('node-stream-zip');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
// bootCrmDb MUST run before requiring the service (which transitively requires
// db.js) so the export reads this test's DB, not the default path.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
({ createPicpeak } = require('../../src/services/picpeakExportService'));
}, 60000);
afterAll(async () => {
await cleanup();
});
async function readZip(filePath) {
const zip = new StreamZip.async({ file: filePath });
const entries = Object.keys(await zip.entries());
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
await zip.close();
return { entries, manifest };
}
describe('picpeak export (.picpeak logical export)', () => {
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
expect(filePath.endsWith('.picpeak')).toBe(true);
expect(fs.existsSync(filePath)).toBe(true);
expect(manifest.format).toBe(1);
expect(manifest.kind).toBe('picpeak-backup');
expect(manifest.database.engine).toBe('sqlite');
expect(manifest.options.includePhotos).toBe(false);
expect(manifest.contains_secrets).toBe(true);
// Migrations seed real tables (e.g. app_settings) — expect several.
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
expect(Object.keys(manifest.tables)).toContain('app_settings');
const { entries, manifest: zipped } = await readZip(filePath);
expect(entries).toContain('manifest.json');
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
expect(entries).toContain('data/app_settings.ndjson');
// Manifest inside the zip matches the returned one.
expect(zipped.tables).toEqual(manifest.tables);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('never exports knex bookkeeping tables', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const names = Object.keys(manifest.tables);
expect(names).not.toContain('knex_migrations');
expect(names).not.toContain('knex_migrations_lock');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('row counts in the manifest match the NDJSON line counts', async () => {
// Insert a couple of settings so at least one table is non-empty.
await db('app_settings')
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
.onConflict('setting_key').merge();
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const zip = new StreamZip.async({ file: filePath });
const buf = await zip.entryData('data/app_settings.ndjson');
await zip.close();
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
});
@@ -0,0 +1,180 @@
'use strict';
// Full .picpeak roundtrip on a temp SQLite DB:
// 1. seed a "backup" instance (admin A + a marker setting)
// 2. export → .picpeak
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
// 4. import the backup with currentAdminId = B
// 5. assert the backup data is restored AND the current account (B) survives,
// while the backup's admin (A) is also present (different email → added).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
let importFromPicpeak;
let validateManifest;
let superAdminRoleId;
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir;
({ createPicpeak } = require('../../src/services/picpeakExportService'));
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 60000);
afterAll(async () => {
await cleanup();
});
const adminRow = (email, hash) => ({
username: email,
email,
password_hash: hash,
role_id: superAdminRoleId,
is_active: true,
must_change_password: false,
created_at: new Date(),
updated_at: new Date(),
});
async function setMarker(value) {
await db('app_settings')
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
.onConflict('setting_key').merge();
}
async function getMarker() {
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
return row ? JSON.parse(row.setting_value) : null;
}
describe('.picpeak roundtrip (export → import)', () => {
it('restores backup data and preserves the current account', async () => {
// 1. Seed the "source" instance.
await db('admin_users').del();
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
await setMarker('from_backup');
// 2. Export.
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// 3. Simulate a reinstall: fresh current admin B, mutated data.
await db('admin_users').del();
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
await setMarker('mutated_after_backup');
// 4. Import, preserving the current admin.
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
expect(result.restored).toBe(true);
expect(result.tables).toBeGreaterThan(0);
// 5a. Backup data restored (marker reverted to the backup value).
expect(await getMarker()).toBe('from_backup');
// 5b. The backup's admin is present (different email → added).
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
expect(a).toBeTruthy();
expect(a.password_hash).toBe('HASH_A');
// 5c. The current account SURVIVES the override, with its own credentials.
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
expect(b).toBeTruthy();
expect(b.password_hash).toBe('HASH_B');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('overwrites a backup admin that collides with the current account email', async () => {
// Source has an admin at the SAME email the current operator will use.
await db('admin_users').del();
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
await setMarker('collision_case');
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// Reinstall: current admin uses the same email but a NEW password.
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
// Exactly one admin at that email, and it keeps the CURRENT password.
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
expect(rows).toHaveLength(1);
expect(rows[0].password_hash).toBe('NEW_HASH');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('restores files/ and reports filesRestored', async () => {
// A business-doc that lives in storage → travels in the backup.
const docDir = path.join(tmpDir, 'business-docs');
const marker = path.join(docDir, 'roundtrip-doc.txt');
fs.mkdirSync(docDir, { recursive: true });
fs.writeFileSync(marker, 'hello');
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
const { filePath } = await createPicpeak({ includePhotos: false });
try {
fs.rmSync(marker); // delete on disk so the restore must bring it back
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
expect(fs.existsSync(marker)).toBe(true);
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
fs.rmSync(docDir, { recursive: true, force: true });
}
});
});
describe('.picpeak manifest validation', () => {
it('rejects a database-engine mismatch', async () => {
// Harness runs on SQLite, so a pg manifest must be refused.
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
});
it('rejects a backup from a newer schema (forward-only)', async () => {
// validateManifest reads knex_migrations for the target's latest migration;
// the harness has none, so create it with an older migration than the backup.
await db.schema.createTable('knex_migrations', (t) => {
t.increments('id');
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
});
try {
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1,
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
tables: {},
});
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
} finally {
await db.schema.dropTableIfExists('knex_migrations');
}
});
it('rejects a file that is not a PicPeak backup', async () => {
const blockers = await validateManifest({ some: 'random-json' });
expect(blockers.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,82 @@
/**
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
*
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
* the script in a child process (--email <addr> --yes) pointed at the same
* DB file, and asserts the four MFA columns are zeroed. The script runs in
* its own process with its own knex connection; the parent connection is
* idle during the spawn so the SQLite write lock isn't contended.
*/
const path = require('path');
const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
async function seedEnrolledAdmin(email) {
const inserted = await db('admin_users').insert({
username: email.split('@')[0],
email,
password_hash: 'x',
is_active: true,
two_factor_enabled: true,
two_factor_secret: 'iv.tag.ct',
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
two_factor_enrolled_at: new Date(),
created_at: new Date(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
}
it('zeroes the four MFA columns for the targeted admin', async () => {
const email = 'reset-me@example.com';
const id = await seedEnrolledAdmin(email);
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
env: {
...process.env,
NODE_ENV: 'test',
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
},
stdio: 'pipe',
});
const row = await db('admin_users').where({ id }).first();
expect(Number(row.two_factor_enabled)).toBe(0);
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
expect(row.two_factor_enrolled_at).toBeNull();
});
it('leaves a different admin untouched', async () => {
const targetEmail = 'target@example.com';
const bystanderEmail = 'bystander@example.com';
const targetId = await seedEnrolledAdmin(targetEmail);
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
stdio: 'pipe',
});
const target = await db('admin_users').where({ id: targetId }).first();
const bystander = await db('admin_users').where({ id: bystanderId }).first();
expect(Number(target.two_factor_enabled)).toBe(0);
expect(Number(bystander.two_factor_enabled)).toBe(1);
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
});
@@ -0,0 +1,259 @@
/**
* Pins the fix for the PR #596 review blocker.
*
* **The bug**
*
* `preservedMeta` was declared with `let` INSIDE the PostgreSQL
* `else` branch of `performDatabaseRestore`, then read AFTER the
* `else` block closed at the shared replay site (~L1030). On every
* real PG restore:
*
* ReferenceError: preservedMeta is not defined
*
* would fire — psql had already completed the data restore, but
* the operator-meta replay never ran, the trigger file was left
* in place by `_installFromBackupBoot.js` because the restore
* "failed", and `combined.log` got a loud FAILED line even though
* the data was back. Caught on PR #596 review by the maintainer.
*
* **Why CI missed it**
*
* The integration tests around `performFullRestore` only exercise
* the SQLite branch via `this.dbType === 'sqlite'`. The PG branch
* (~L827-984) requires a real PG connection + real `psql` binary,
* neither of which are in the test environment. So the scope leak
* sat untested until the maintainer ran a real DR cycle.
*
* **What this test does**
*
* Reads the source of `restoreService.js` and asserts the scope
* contract: the `preservedMeta` declaration sits ABOVE the
* SQLite/PG branch split, so the replay block at the bottom of the
* try{} can read it on either branch.
*
* Source-inspection is uglier than a runtime test but it has two
* advantages here: (a) it doesn't require a real PG cluster + psql
* binary in CI, (b) it pins the EXACT contract — "the declaration
* must be visible to the replay block" — which is the property
* that broke, more directly than a runtime test would.
*
* The follow-up "real-PG integration test in CI" (separate task)
* would replace this with an end-to-end exercise, at which point
* this can be deleted.
*/
const fs = require('fs');
const path = require('path');
describe('restoreService — PG branch scope contract (PR #596 review)', () => {
let src;
let lines;
beforeAll(() => {
src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'services', 'restoreService.js'),
'utf8',
);
lines = src.split(/\r?\n/);
});
/** Return the 1-based line number of the FIRST line matching `re`. */
function findFirst(re) {
const idx = lines.findIndex((l) => re.test(l));
return idx >= 0 ? idx + 1 : -1;
}
/** Return the 1-based line number of the LAST line matching `re`. */
function findLast(re) {
let last = -1;
lines.forEach((l, i) => { if (re.test(l)) last = i + 1; });
return last;
}
it('preservedMetaSnapshot lives on `this` and is initialised in the constructor', () => {
// PR #596 round 3 moved the snapshot from a block-scoped local to
// an instance variable so the replay can happen in `restore()`
// AFTER post-restore verification — preventing the replay row
// from inflating the row-count check.
//
// Contract:
// 1. The constructor initialises `this.preservedMetaSnapshot = []`
// 2. The `restore()` entry point resets it per call (no leak
// across consecutive runs in the singleton service instance)
// 3. `performDatabaseRestore` assigns to `this.preservedMetaSnapshot`
// inside the PG branch (must run before DROP)
// 4. The replay reads `this.preservedMetaSnapshot` — NOT a bare
// `preservedMeta` local — so a future refactor can't
// accidentally drop the snapshot half on the floor again.
const constructorInit = lines.some((l) =>
/this\.preservedMetaSnapshot\s*=\s*\[\s*\]/.test(l)
);
expect(constructorInit).toBe(true);
const assignmentSites = lines.filter((l) =>
/this\.preservedMetaSnapshot\s*=\s*(\[\s*\]|await\s+db)/.test(l)
);
// Constructor init + restore() per-run reset + the PG-branch
// assignment from db query. Three writes.
expect(assignmentSites.length).toBeGreaterThanOrEqual(3);
// No stray bare `preservedMeta` local-scoped declaration in
// performDatabaseRestore — would indicate someone re-introduced
// the round-1 footgun.
const dangerousLocalDecl = lines.filter((l) =>
/^\s*(let|const)\s+preservedMeta\s*=/.test(l)
);
expect(dangerousLocalDecl).toEqual([]);
});
it('every .count() result is coerced to Number before comparison', () => {
// PR #596 review caught a second PG-only landmine: pg-driver
// returns COUNT(*) as a string ("16" not 16) to preserve bigint
// precision. The original code compared `result.count !==
// expected.rowCount` and every match flagged as a mismatch on PG.
//
// The fix coerces with `Number(...)` at every comparison +
// interpolation site. This test catches a future regression where
// a refactor uses `.count` directly in a `===` / `!==` / `>` /
// `<` comparison without coercing.
//
// Heuristic: find every `.count` access in the file and make sure
// the line either:
// (a) wraps it in `Number(...)`, or
// (b) is purely an interpolation that already coerced upstream
// (e.g. `validation.warnings.push(`... ${eventCountN} ...`)`
// where eventCountN is the coerced local), or
// (c) is the docstring/comment line (filtered separately).
//
// We approximate this by listing every `.count` reference site
// and asserting that lines doing comparisons (`===`/`!==`/`>`/
// `<`/`>=`/`<=`) on a raw `.count` access without `Number(...)`
// around it are zero.
const dangerousLines = lines
.map((l, i) => ({ line: i + 1, text: l }))
// Filter to lines that compare a .count result
.filter(({ text }) => {
// Skip comments
if (/^\s*(\/\/|\*)/.test(text)) return false;
// Detect a `.count` (followed by `)` for `?.count` or by space/operator)
// being directly compared via ===/!==/>/<.
// Match the BAD pattern: `<something>.count <op> <something>`
// where <op> is === / !== / > / < / >= / <=
const bareCountInComparison = /\w+\??\.count\s*(?:!==|===|>=?|<=?)\s+/;
// ALLOW if the .count is preceded by `Number(` in the same line
const wrappedInNumber = /Number\(\s*\w+\??\.count/;
return bareCountInComparison.test(text) && !wrappedInNumber.test(text);
});
expect(dangerousLines).toEqual([]);
});
it('the completed-restore update sets was_successful=true', () => {
// Without this, every successful restore ends up with
// status='completed', was_successful=false — the dashboard's
// "last successful restore" widget then filters out the row +
// any future audit query gating on was_successful misses it.
// Caught locally + maintainer PR #596 review.
//
// Contract: the update payload that writes status='completed' on
// the SUCCESS branch ALSO includes was_successful: true. We pin
// it by source inspection so any future refactor of the success
// payload keeps both fields together.
// The success-branch update lives AFTER performPostRestoreVerification.
// There's also a `status: 'completed'` in the dry-run / early-return
// path (failure handling has its own block too) — we want the
// SUCCESS-branch one specifically.
const verifyLine = findFirst(/performPostRestoreVerification\s*\(/);
expect(verifyLine).toBeGreaterThan(0);
const completedStatusLineIdx = lines
.map((l, i) => ({ line: i + 1, text: l }))
.find(({ line, text }) =>
line > verifyLine && /status:\s*['"]completed['"]/.test(text)
);
expect(completedStatusLineIdx).toBeDefined();
// Look in the next ~10 lines for was_successful: true. The actual
// payload is small (no nested objects between status and the
// closing })), so a fixed-window search is reliable.
const window = lines.slice(
completedStatusLineIdx.line - 1,
completedStatusLineIdx.line + 10,
).join('\n');
expect(window).toMatch(/was_successful:\s*true/);
});
it('npm run migrate:safe is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The
// restore() flow shells out to `npm run migrate:safe` AFTER the
// operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container
// restart).
//
// Contract:
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/['"]migrate:safe['"]/);
expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
expect(replayLine).toBeGreaterThan(0);
expect(migrateLine).toBeGreaterThan(replayLine);
// Must NOT live inside performDatabaseRestore (same scope as the
// replay check above).
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
let dbRestoreEnd = -1;
for (let i = dbRestoreStart; i < lines.length; i++) {
if (/^ \}\s*$/.test(lines[i])) {
dbRestoreEnd = i + 1;
break;
}
}
expect(migrateLine < dbRestoreStart || migrateLine > dbRestoreEnd).toBe(true);
});
it('the replay site lives in restore() AFTER performPostRestoreVerification', () => {
// PR #596 round 3 moved the replay out of performDatabaseRestore
// and into the parent restore() method, sequenced AFTER the
// post-restore verification. Otherwise the replay's upserted row
// count was being flagged as a verification mismatch (e.g.
// "expected 190, got 191" because the fresh-install seeded
// `restore_allow_force_auto_upgraded` that wasn't in the backup).
//
// Contract: the line that drains `this.preservedMetaSnapshot`
// must come AFTER `performPostRestoreVerification` AND must NOT
// sit inside `performDatabaseRestore`.
const verificationLine = findFirst(/performPostRestoreVerification\s*\(/);
expect(verificationLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
expect(replayLine).toBeGreaterThan(0);
expect(replayLine).toBeGreaterThan(verificationLine);
// `performDatabaseRestore` must not contain the replay drain.
// Find the function bounds + assert no drain line falls inside.
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
expect(dbRestoreStart).toBeGreaterThan(0);
// Find the closing brace of performDatabaseRestore. Lazy heuristic:
// the first `^ \}\s*$` (two-space indent + }) after the function
// start. Brittle to indent changes but unambiguous in this codebase.
let dbRestoreEnd = -1;
for (let i = dbRestoreStart; i < lines.length; i++) {
if (/^ \}\s*$/.test(lines[i])) {
dbRestoreEnd = i + 1;
break;
}
}
expect(dbRestoreEnd).toBeGreaterThan(dbRestoreStart);
// The replay drain line must be OUTSIDE [dbRestoreStart, dbRestoreEnd].
expect(replayLine < dbRestoreStart || replayLine > dbRestoreEnd).toBe(true);
});
});
@@ -0,0 +1,196 @@
'use strict';
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
// so setupService shares this test's db instance (see crmDb.js note).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let setupService;
let getAppSetting;
let upsertAppSetting;
let app;
const VALID_PW = 'Str0ng-Passw0rd!';
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
// service/util), or db.js binds to the default path instead of the temp one.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 60000);
afterAll(async () => {
await cleanup();
});
beforeEach(async () => {
await db('admin_users').del();
await db('app_settings').where({ setting_key: 'setup_token' }).del();
});
describe('setupService (first-run bootstrap)', () => {
it('reports needsAdmin while no admin exists', async () => {
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('generates and persists a one-time token while no admin exists', async () => {
const token = await setupService.ensureSetupToken();
expect(token).toEqual(expect.any(String));
expect(token.length).toBeGreaterThan(20);
expect(await getAppSetting('setup_token')).toBe(token);
// Idempotent — a second call returns the same token, not a fresh one.
expect(await setupService.ensureSetupToken()).toBe(token);
});
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
// Regression guard for the SQLite-only miss: a bare token string is rejected
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
// value must be JSON-parseable and round-trip back to the token.
const token = await setupService.ensureSetupToken();
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
expect(() => JSON.parse(row.setting_value)).not.toThrow();
expect(JSON.parse(row.setting_value)).toBe(token);
});
it('rejects a wrong token', async () => {
await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 400 });
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('rejects a weak password', async () => {
const token = await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
).rejects.toMatchObject({ statusCode: 400 });
});
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
const token = await setupService.ensureSetupToken();
const result = await setupService.createInitialAdmin({
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
});
expect(result.user.email).toBe('owner@example.com'); // normalised
expect(result.user.role.name).toBe('super_admin');
expect(result.token).toEqual(expect.any(String));
const row = await db('admin_users').first();
const role = await db('roles').where({ name: 'super_admin' }).first();
expect(row.role_id).toBe(role.id);
expect(row.password_hash).not.toBe(VALID_PW); // hashed
// One-time: token burned, status now complete.
expect(await getAppSetting('setup_token')).toBeFalsy();
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
const token = await setupService.ensureSetupToken();
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
});
it('refuses to create a second admin (setup already complete)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
await expect(
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 409 });
});
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
const token = await setupService.ensureSetupToken();
const results = await Promise.allSettled([
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
]);
const fulfilled = results.filter((r) => r.status === 'fulfilled');
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
const count = await db('admin_users').count({ c: '*' }).first();
expect(Number(count.c)).toBe(1);
});
it('ensureSetupToken clears any stale token once an admin exists', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
// Simulate a stale token left in settings, then re-run the boot hook.
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
expect(await setupService.ensureSetupToken()).toBeNull();
expect(await getAppSetting('setup_token')).toBeFalsy();
});
});
describe('setup routes', () => {
it('GET /api/setup/status reports needsAdmin', async () => {
const res = await request(app).get('/api/setup/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsAdmin: true, complete: false });
});
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(200);
expect(res.body).toEqual({ valid: true });
// Token is NOT consumed — it still works for the actual create.
expect(await getAppSetting('setup_token')).toBe(token);
});
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
expect(res.status).toBe(400);
expect(res.body.field).toBe('token');
});
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(409);
});
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
expect(res.status).toBe(400);
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
});
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'owner@example.com', password: VALID_PW });
expect(res.status).toBe(201);
expect(res.body.user.role.name).toBe('super_admin');
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'second@example.com', password: VALID_PW });
expect(res.status).toBe(409);
});
});
@@ -0,0 +1,661 @@
/**
* Workflow engine — graph execution integration tests.
*
* Exercises the engine against a real (temp SQLite) DB with migration 142
* applied: branching, bounded loops, wait pauses + scheduler-style resume,
* gate pauses + confirm/deny resume, dedup idempotency, and step recording.
*/
const { bootCrmDb } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let engine;
async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) {
const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled });
const workflowId = ins[0];
for (const n of nodes) {
await db('workflow_nodes').insert({
workflow_id: workflowId, version: 1, node_key: n.key, type: n.type,
config: JSON.stringify(n.config || {}),
});
}
for (const e of edges) {
await db('workflow_edges').insert({
workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to,
loop_back: e.loopBack || false,
});
}
return workflowId;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Engine requires the singleton db — require AFTER bootCrmDb wired the test path.
engine = require('../../src/services/workflows');
// Enable the workflows flag so emitWorkflowEvent doesn't fail closed.
await db('feature_flags').insert({ key: 'workflows', value: true });
});
afterAll(async () => { await cleanup(); });
describe('workflow engine', () => {
test('condition + bounded loop + wait pauses, resumes to completion', async () => {
// trigger → set paid=false → condition(paid?) --no--> loop(max2)
// loop --loop--> reminder(noop) → wait → (back to condition)
// loop --exit--> lateFee(noop) → end
// condition --yes--> lateFee (paid path, not taken here)
const wfId = await makeWorkflow({
nodes: [
{ key: 'n1', type: 'trigger' },
{ key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } },
{ key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } },
{ key: 'n4', type: 'loop', config: { maxIterations: 2 } },
{ key: 'n5', type: 'action', config: { action: 'noop' } },
{ key: 'n6', type: 'wait', config: { delayMinutes: 0 } },
{ key: 'n7', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'n1', to: 'n2' },
{ from: 'n2', to: 'n3' },
{ from: 'n3', handle: 'no', to: 'n4' },
{ from: 'n3', handle: 'yes', to: 'n7' },
{ from: 'n4', handle: 'loop', to: 'n5' },
{ from: 'n4', handle: 'exit', to: 'n7' },
{ from: 'n5', to: 'n6' },
{ from: 'n6', to: 'n3', loopBack: true },
],
});
const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 });
expect(runIds.length).toBe(1);
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1)
expect(run.current_node).toBe('n6');
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused again (loop iter 2)
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // loop exhausted → exit → end
const ctx = JSON.parse(run.context);
expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap
void wfId;
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.length).toBeGreaterThan(0);
});
test('emit is idempotent on dedup_key', async () => {
await makeWorkflow({
trigger: 'dedup.event',
nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'n1', to: 'n2' }],
});
const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
expect(first.length).toBe(1);
expect(second.length).toBe(0); // same entity → no duplicate run
});
test('gate pauses and resumes via the confirm edge', async () => {
const wfId = await makeWorkflow({
trigger: 'gate.event',
nodes: [
{ key: 'g1', type: 'trigger' },
{ key: 'g2', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
{ key: 'g4', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g1', to: 'g2' },
{ from: 'g2', handle: 'confirm', to: 'g3' },
{ from: 'g2', handle: 'deny', to: 'g4' },
],
});
// create + start a run directly
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending',
context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test',
});
const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first();
await engine.startRun(run0.id);
let run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2');
await engine.resumeRun(run0.id, { decisionHandle: 'confirm' });
run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('runDueWaits resumes only elapsed wait nodes', async () => {
await makeWorkflow({
trigger: 'wait.event',
nodes: [
{ key: 'w1', type: 'trigger' },
{ key: 'w2', type: 'wait', config: { delayMinutes: 60 } },
{ key: 'w3', type: 'action', config: { action: 'noop' } },
],
edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }],
});
const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 });
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
const resumed = await engine.runDueWaits();
expect(resumed).toBeGreaterThanOrEqual(1);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('send_email queues a customer mail with business-hours routing', async () => {
await makeWorkflow({
trigger: 'mail.event',
nodes: [
{ key: 'm1', type: 'trigger' },
{ key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } },
],
edges: [{ from: 'm1', to: 'm2' }],
});
const runIds = await engine.emitWorkflowEvent('mail.event', {
entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' },
});
const run = await db('workflow_runs').where({ id: runIds[0] }).first();
expect(run.status).toBe('done');
const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first();
expect(queued).toBeTruthy();
const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first();
expect(JSON.parse(step.result).respectBusinessHours).toBe(true);
});
test('invoice_paid condition reads the entity', async () => {
const registry = require('../../src/services/workflows/registry');
const cond = registry.getCondition('invoice_paid');
const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) });
expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false);
});
test('gate creates a pending approval + admin email, token confirm resumes the run', async () => {
await makeWorkflow({
trigger: 'approval.event',
nodes: [
{ key: 'a1', type: 'trigger' },
{ key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } },
{ key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path
{ key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path
],
edges: [
{ from: 'a1', to: 'a2' },
{ from: 'a2', handle: 'confirm', to: 'a3' },
{ from: 'a2', handle: 'deny', to: 'a4' },
],
});
const runIds = await engine.emitWorkflowEvent('approval.event', {
entityType: 'invoice', entityId: 42, payload: { adminEmail: 'admin@example.com' },
});
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('a2');
const approval = await db('workflow_approvals').where({ run_id: runId }).first();
expect(approval).toBeTruthy();
expect(approval.status).toBe('pending');
const adminMail = await db('email_queue').where({ recipient_email: 'admin@example.com' }).first();
expect(adminMail).toBeTruthy();
// Extract the raw token from the emailed confirm link and act on it.
const data = JSON.parse(adminMail.email_data);
const rawToken = data.confirm_url.split('/').slice(-2)[0];
const res = await engine.actByToken(rawToken, 'confirm');
expect(res.ok).toBe(true);
expect(res.status).toBe('confirmed');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
// A second click is idempotent (already recorded).
const again = await engine.actByToken(rawToken, 'confirm');
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
expect(all.length).toBe(1);
});
test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
// Simulate an older, never-touched seed (v1, with a legacy gate node).
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
// Admin-owned (admin_toggled_at set) + stale → must NOT be touched.
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) });
const before = await db('workflows').where({ id: wf.id }).first();
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const after = await db('workflows').where({ id: wf.id }).first();
expect(after.version).toBe(before.version); // unchanged
expect(!!after.enabled).toBe(true); // admin's choice preserved
});
test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// First beta: cutover flows ship DISABLED (legacy paths run until enabled);
// they delegate to the proven send functions once turned on.
const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
expect(expiring).toBeTruthy();
expect(!!expiring.enabled).toBe(false);
expect(expiring.trigger_type).toBe('gallery.expiring');
const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
expect(expired).toBeTruthy();
expect(!!expired.enabled).toBe(false);
expect(expired.trigger_type).toBe('gallery.expired');
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
// Invoice-only booking variant (quote → invoice, no gallery).
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
expect(invoiceOnly).toBeTruthy();
expect(!!invoiceOnly.enabled).toBe(false);
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
// Admin review gate guards BOTH document sends (adjust line items, then OK).
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
// dispatch is held until the event date after the admin's early OK.
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
expect(bookingSimple).toBeTruthy();
expect(bookingSimple.trigger_type).toBe('quote.accepted');
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
expect(!!preEvent.enabled).toBe(false); // first beta: ships disabled
expect(preEvent.trigger_type).toBe('event.date_approaching');
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
});
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
const wfId = await makeWorkflow({
trigger: 'event.date_approaching',
enabled: true,
nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'pe1', to: 'pe2' }],
});
// Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' };
await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
const emitted = await engine.emitDueEventReminders();
expect(emitted).toBeGreaterThanOrEqual(1);
const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs.length).toBe(1); // only the in-window event, not the far one
// Idempotent: a second pass dedups (no duplicate run for the same event).
await engine.emitDueEventReminders();
const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs2.length).toBe(1);
});
test('notify_pre_event / sendReminderForEvent sends to an event with a direct email (no CRM account)', async () => {
// Regression: the reminder query used events.customer_account_id, which does
// not exist — so an event with only customer_email/host_email got no mail.
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture,
is_active: true, is_archived: false,
slug: 'rem-direct', share_link: 'rem-direct', event_name: 'Direct',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
customer_email: 'direct@x.test', // event-level email, NOT a customer_account
});
const ev = await db('events').where({ slug: 'rem-direct' }).first();
const res = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ event_id: ev.id }).first();
expect(mail).toBeTruthy();
expect(mail.recipient_email).toBe('direct@x.test');
// Idempotent: sent_at stamped → a second call is a no-op.
const again = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(again.sent).toBe(0);
expect(again.reason).toBe('already_sent');
});
test('reminder template resolves per event type within the chosen group, else group default', async () => {
const { _internal } = require('../../src/services/eventReminderService');
// Per-type template exists within a custom group → used.
await db('email_templates').insert({ template_key: 'promo_wedding' });
expect(await _internal.resolveTemplateKey('wedding', 'promo')).toBe('promo_wedding');
// A type with no authored template (in any group) → the group's default.
expect(await _internal.resolveTemplateKey('zzznotype', 'promo')).toBe('promo_default');
// Blank group → the default event_reminder group.
expect(await _internal.resolveTemplateKey('zzznotype')).toBe('event_reminder_default');
// Trailing underscore on the group is tolerated.
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
});
test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
const { _internal } = require('../../src/services/eventReminderService');
const p = _internal.composePayload({
event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
recipientEmail: 'a@x.test', daysBefore: 2, businessName: 'Biz',
});
expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
expect(p.event_date).not.toMatch(/invalid/i);
});
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
const webhook = engine.registry.getAction('webhook');
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
const ctx = (config, vars = {}) => ({
run: { id: 1, workflow_id: 1, version: 1, trigger_event: 'invoice.sent', entity_type: 'invoice', entity_id: 5 },
node: { config }, vars, db, logger: { warn() {} },
});
// No webhook selected → observable skip, not a crash.
expect(await webhook(ctx({}))).toMatchObject({ skipped: true });
// A configured, active webhook subscription.
const [adminId] = await db('admin_users').insert({ username: 'wfhook', email: 'wf@x.test', password_hash: 'x' });
const [whId] = await db('webhooks').insert({
name: 'Flow hook', url: 'https://example.com/hook', secret: 'whsec_test',
events: JSON.stringify([]), active: true, created_by: adminId,
});
// Dry run does not enqueue.
expect(await webhook(ctx({ webhookId: whId }, { __dryRun: true }))).toMatchObject({ dryRun: true, would: 'webhook' });
expect(await db('webhook_deliveries').where({ webhook_id: whId }).count('id as c').first()).toMatchObject({ c: 0 });
// Real run → a pending delivery is enqueued for the worker (which does the
// signing + SSRF re-validation + retries).
const res = await webhook(ctx({ webhookId: whId }));
expect(res.webhook_enqueued).toBe(whId);
const del = await db('webhook_deliveries').where({ webhook_id: whId }).first();
expect(del).toBeTruthy();
expect(del.status).toBe('pending');
expect(del.event_type).toBe('workflow.invoice.sent');
// Inactive / missing subscription → skip.
await db('webhooks').where({ id: whId }).update({ active: false });
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
});
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
const eventReminderService = require('../../src/services/eventReminderService');
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [custId] = await db('customer_accounts').insert({
email: 'assigned@x.test', preferred_language: 'en', is_active: true, created_at: new Date(),
});
// Event with NO inline customer_email / host_email.
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
});
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
const res = await eventReminderService.sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ recipient_email: 'assigned@x.test' }).first();
expect(mail).toBeTruthy();
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
expect(mail.event_id == null).toBe(true);
});
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// All built-ins ship disabled → inactive until the admin enables one.
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false);
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
// Enable one → now active.
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: true });
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: false }); // restore
});
test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED
// crm_event_reminders_enabled must be on to reach the mutex guard.
await db('app_settings')
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
.onConflict('setting_key').merge();
const eventReminderService = require('../../src/services/eventReminderService');
// Flow disabled → guard does NOT fire (legacy pass owns reminders).
expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(false);
// Flow enabled → the pass stands down before doing any work (byWorkflow).
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: true });
const after = await eventReminderService.runEventReminderPass();
expect(after.byWorkflow).toBe(true);
expect(after.sent).toBe(0);
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: false }); // restore
});
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
// Two enabled flows on the same trigger — the quote picks one.
const chosen = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
const other = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'o1', to: 'o2' }],
});
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
expect(runIds.length).toBe(1);
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
expect(chosenRuns.length).toBe(1); // only the selected flow ran
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
});
test('gate decision with no matching edge FAILS the run (not a silent done)', async () => {
// Gate has a confirm edge but the deny edge was lost (e.g. a bad import).
const wfId = await makeWorkflow({
trigger: 'noedge.event', enabled: true,
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: {} },
{ key: 'g2', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' }, // no deny edge
],
});
const [runId] = await engine.emitWorkflowEvent('noedge.event', { entityType: 'x', entityId: 1 });
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'deny'); // deny has no edge
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('failed'); // loud failure, not a green 'done'
expect(run.error).toMatch(/deny.*no matching edge/i);
});
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
// admin can approve at the gate whenever; the run then parks at the wait and
// the scheduler dispatches when the date arrives.
const wfId = await makeWorkflow({
trigger: 'gatewait.event',
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' },
{ from: 'g2', to: 'g3' },
],
});
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g1'); // parked at the review gate
// Admin confirms EARLY (before the wait date).
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'confirm');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
// Date arrives → scheduler dispatches.
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
await engine.runDueWaits();
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'r1', to: 'r2' }],
});
// Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow).
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2',
context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1',
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first();
const n = await engine.recoverStaleRuns({ staleMs: 1000 });
expect(n).toBeGreaterThanOrEqual(1);
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => {
const wfId = await makeWorkflow({
trigger: 'crashloop.event',
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2',
context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5,
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first();
await engine.recoverStaleRuns({ staleMs: 1000 });
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('failed');
});
test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
const wfId = await makeWorkflow({
trigger: 'testfire.event',
nodes: [
{ key: 't', type: 'trigger' },
{ key: 'w', type: 'wait', config: { delayDays: 14 } },
{ key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
{ key: 'end', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 't', to: 'w' },
{ from: 'w', to: 'g' },
{ from: 'g', handle: 'confirm', to: 'a' },
{ from: 'g', handle: 'deny', to: 'end' },
{ from: 'a', to: 'end' },
],
});
const runId = await engine.testRun(wfId, { dryRun: true });
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
const emailStep = steps.find((s) => s.node_key === 'a');
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
});
});
@@ -0,0 +1,147 @@
/**
* Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals).
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let app;
let token;
let noPermToken;
const sampleGraph = {
name: 'Test flow',
trigger_type: 'invoice.sent',
enabled: false,
nodes: [
{ node_key: 'n1', type: 'trigger' },
{ node_key: 'n2', type: 'action', config: { action: 'noop' } },
],
edges: [{ from_node: 'n1', to_node: 'n2' }],
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
const ins = await db('admin_users').insert({
username: 'norole', email: 'nr@example.com', password_hash: 'x',
must_change_password: false, created_at: new Date(),
}).returning('id');
noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]);
await db('feature_flags').insert({ key: 'workflows', value: true });
app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows'));
});
afterAll(async () => { await cleanup(); });
const auth = (t) => ({ Authorization: `Bearer ${t}` });
describe('admin workflows API', () => {
let createdId;
test('create → 201 with id', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph);
expect(res.status).toBe(201);
expect(res.body.id).toBeGreaterThan(0);
createdId = res.body.id;
});
test('rejects a graph without exactly one trigger', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] });
expect(res.status).toBe(400);
});
test('rejects an unknown node type', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'x', type: 'actoin' }], edges: [] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/unknown node type/i);
});
test('refuses to enable a flow that uses an unregistered action', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false,
nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'totally_not_a_real_action' } }],
edges: [{ from_node: 't', to_node: 'a' }],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/not.*implemented|totally_not_a_real_action/i);
});
test('allows enabling a flow using the now-implemented booking invoice actions', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false,
nodes: [
{ node_key: 't', type: 'trigger' },
{ node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } },
{ node_key: 'g', type: 'gate', config: {} },
{ node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } },
],
edges: [
{ from_node: 't', to_node: 'p' },
{ from_node: 'p', to_node: 'g' },
{ from_node: 'g', from_handle: 'confirm', to_node: 's' },
],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('get one returns the graph', async () => {
const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(res.status).toBe(200);
expect(res.body.nodes).toHaveLength(2);
expect(res.body.edges).toHaveLength(1);
expect(res.body.version).toBe(1);
});
test('list includes it', async () => {
const res = await request(app).get('/api/admin/workflows').set(auth(token));
expect(res.status).toBe(200);
expect(res.body.some((w) => w.id === createdId)).toBe(true);
});
test('update bumps the version', async () => {
const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token))
.send({ ...sampleGraph, name: 'Renamed' });
expect(res.status).toBe(200);
expect(res.body.version).toBe(2);
const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(get.body.name).toBe('Renamed');
expect(get.body.version).toBe(2);
});
test('enable toggle', async () => {
const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('approvals inbox returns an array', async () => {
const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token));
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
test('a role without workflows.manage is forbidden from writing', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph);
expect(res.status).toBe(403);
});
});
@@ -0,0 +1,78 @@
/**
* Regression test for the bulk archive/delete ownership bypass.
*
* bulk-archive and bulk-delete acted on body-supplied event ids with no
* ownership filter, so an admin/editor scoped to their own events (the
* single-event routes enforce requireEventOwnership) could archive or
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
* routes now use to drop foreign/non-existent ids.
*/
// events owned by admin 7; event 3 owned by someone else; event 4 is
// ownerless (legacy). The mock models:
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
const EVENTS = [
{ id: 1, created_by: 7 },
{ id: 2, created_by: 7 },
{ id: 3, created_by: 99 }, // foreign
{ id: 4, created_by: null }, // ownerless/legacy
];
jest.mock('../../src/database/db', () => ({
db: () => {
const q = {
_ids: null,
_adminId: null,
whereIn(_col, ids) { this._ids = ids; return this; },
andWhere(cb) {
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
// by capturing the admin id the callback closes over via a probe.
const probe = {
_adminId: null,
whereNull() { return this; },
orWhere(_col, id) { this._adminId = id; return this; },
};
cb(probe);
this._adminId = probe._adminId;
return this;
},
select() {
return Promise.resolve(
EVENTS
.filter((e) => this._ids.includes(e.id))
.filter((e) => e.created_by === null || e.created_by === this._adminId)
.map((e) => ({ id: e.id }))
);
},
};
return q;
},
}));
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
describe('filterOwnedEventIds', () => {
it('super_admin gets every id, nothing denied', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
);
expect(allowed).toEqual([1, 3, 4, 999]);
expect(denied).toEqual([]);
});
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
);
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
});
it('foreign-only request yields empty allowed', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'editor' }, [3]
);
expect(allowed).toEqual([]);
expect(denied).toEqual([3]);
});
});
@@ -0,0 +1,103 @@
/**
* Regression test for the cross-event thumbnail enumeration leak.
*
* Thumbnails are served flat from /thumbnails/thumb_<name> with
* deterministic, enumerable filenames. photoAuth previously granted any
* holder of a gallery token for ANY active event access to ANY thumbnail
* (it set eventSlug=null and returned next() as long as the token's event
* existed), so a visitor to one gallery could pull another (password-
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
* access to the token's event by matching the requested file against
* photos.thumbnail_path for that event_id.
*/
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
const jwt = require('jsonwebtoken');
// Two events, each owning one thumbnail. The photos mock resolves a row
// only when BOTH event_id and thumbnail_path match — i.e. it models the
// real ownership query.
const EVENTS = [
{ id: 10, slug: 'event-a', is_active: 1 },
{ id: 20, slug: 'event-b', is_active: 1 },
];
const PHOTOS = [
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
];
jest.mock('../../src/database/db', () => ({
db: (table) => ({
_cond: null,
where(cond) { this._cond = cond; return this; },
first() {
if (table === 'events') {
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
}
if (table === 'photos') {
return Promise.resolve(
PHOTOS.find((p) => p.event_id === this._cond.event_id
&& p.thumbnail_path === this._cond.thumbnail_path) || null
);
}
return Promise.resolve(null);
},
}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const photoAuth = require('../../src/middleware/photoAuth');
function galleryToken(eventId) {
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
}
function makeReqRes(token, thumbPath) {
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
return { req, res };
}
describe('photoAuth — thumbnail ownership scoping', () => {
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
// Access denied: middleware must not pass the request through.
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.event).toMatchObject({ id: 20 });
});
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
});
@@ -0,0 +1,159 @@
/**
* HTTP route auth-gate tests for the CRM admin surface (P1 / P2 — #570).
*
* Bundled into one file rather than nine because the contract is the
* same for every CRM admin route:
* - No token → 401 (adminAuth at the router level)
* - Valid token, missing permission → 403 (requirePermission middleware)
* - Valid token + super_admin role → 2xx / 404 (resource-based)
*
* Deeper service-layer behaviour (PDF generation, send, Storno,
* countersign, integrity hash) is covered by the existing service
* unit tests in __tests__/services/. This file pins the contract
* between the HTTP layer and the auth+permission middleware so a
* misconfigured route ("forgot requirePermission") can never ship
* unnoticed.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admincrm-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole,
mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
// One row per admin CRM route. `mount` matches server.js's app.use,
// `loader` is the require()'d router, `getPath` is one path on the
// router we'll exercise. The path should be a GET-shaped read where
// possible — listing endpoints (`/`) are safest because they don't
// require pre-seeded resource ids.
const ROUTES = [
{ name: 'adminQuotes', mount: '/api/admin/quotes', loader: () => require('../../src/routes/adminQuotes'), getPath: '/' },
{ name: 'adminContracts', mount: '/api/admin/contracts', loader: () => require('../../src/routes/adminContracts'), getPath: '/' },
{ name: 'adminInvoices', mount: '/api/admin/invoices', loader: () => require('../../src/routes/adminInvoices'), getPath: '/' },
{ name: 'adminCalendar', mount: '/api/admin/calendar', loader: () => require('../../src/routes/adminCalendar'), getPath: '/items?from=2026-01-01&to=2026-12-31' },
{ name: 'adminDeals', mount: '/api/admin/deals', loader: () => require('../../src/routes/adminDeals'), getPath: '/' },
{ name: 'adminTaxReport', mount: '/api/admin/tax-report', loader: () => require('../../src/routes/adminTaxReport'), getPath: '/?period=2026-Q1' },
{ name: 'adminBusinessProfile', mount: '/api/admin/business-profile', loader: () => require('../../src/routes/adminBusinessProfile'), getPath: '/' },
];
describe('admin CRM routes — auth + permission gate', () => {
let db;
let cleanup;
let adminId;
let customerId;
let superAdminToken;
let invalidToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
// Super-admin: assign the seeded super_admin role (created by
// migration 057). requirePermission lookups short-circuit because
// super_admin role inherits every permission via role_permissions
// rows seeded by mig 107 and earlier.
await assignAdminRole(db, adminId, 'super_admin');
superAdminToken = mintAdminToken(adminId);
// CRM routes have a feature-flag gate that runs INSIDE the route
// handler — even a super-admin gets 403 (`QUOTES_DISABLED` /
// similar) when the flag is off. The flag check is independent
// of permissions, so for happy-path tests we flip every CRM flag
// on. Negative tests (no-token, bad-signature) hit adminAuth
// first and never reach the flag check, so they're unaffected.
// `accounting` is the master flag the tax-report route now requires
// (tax export moved out of CRM into Accounting, independent of bills).
const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients', 'accounting'];
for (const key of crmFlags) {
// eslint-disable-next-line no-await-in-loop
await db('feature_flags').where({ key }).update({ value: 1 });
}
// Invalid: signed with a different secret. adminAuth must reject.
const jwt = require('jsonwebtoken');
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe.each(ROUTES)('$name', ({ mount, loader, getPath }) => {
let app;
beforeAll(() => {
app = buildRouteApp(mount, loader());
});
it('returns 401 with no Authorization header', async () => {
const res = await request(app).get(`${mount}${getPath}`);
expect(res.status).toBe(401);
});
it('returns 401 with an invalid JWT signature', async () => {
const res = await request(app)
.get(`${mount}${getPath}`)
.set('Authorization', `Bearer ${invalidToken}`);
expect(res.status).toBe(401);
});
it('returns 2xx (or resource-shaped 4xx) with a valid super-admin token', async () => {
const res = await request(app)
.get(`${mount}${getPath}`)
.set('Authorization', `Bearer ${superAdminToken}`);
// 200 if listing succeeds (likely empty list), 400 if a
// validator complains about query shape, 404 if the route
// doesn't have a list endpoint at `/`. What MUST NOT happen:
// 401 (auth gate failed) or 403 (permission gate failed).
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
describe('adminCustomers — CRM additions (hour-entries / bill / trigger-monthly-bill)', () => {
let app;
beforeAll(() => {
app = buildRouteApp('/api/admin/customers', require('../../src/routes/adminCustomers'));
});
it('GET /:id/hour-entries — 401 without token', async () => {
const res = await request(app).get(`/api/admin/customers/${customerId}/hour-entries`);
expect(res.status).toBe(401);
});
it('GET /:id/hour-entries — 2xx with super-admin token', async () => {
const res = await request(app)
.get(`/api/admin/customers/${customerId}/hour-entries`)
.set('Authorization', `Bearer ${superAdminToken}`);
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
it('POST /:id/hour-entries/bill — 401 without token', async () => {
const res = await request(app)
.post(`/api/admin/customers/${customerId}/hour-entries/bill`)
.send({});
expect(res.status).toBe(401);
});
it('POST /:id/trigger-monthly-bill — 401 without token', async () => {
const res = await request(app)
.post(`/api/admin/customers/${customerId}/trigger-monthly-bill`)
.send({});
expect(res.status).toBe(401);
});
});
});
@@ -0,0 +1,200 @@
/**
* HTTP smoke tests for the core admin event CRUD endpoints:
* POST /api/admin/events (create)
* GET /api/admin/events (list + pagination)
* GET /api/admin/events/:id (detail + stats)
* PUT /api/admin/events/:id (update)
* DELETE /api/admin/events/:id (cascade delete)
*
* Safety net ahead of the adminEvents.js god-file decomposition —
* pins the request/response contracts of the main CRUD paths using
* the same real-SQLite harness as slideshowAdmin.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-smoke-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-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: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
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 events CRUD endpoints (smoke)', () => {
let db; let cleanup; let app; let adminId; let token;
// bootCrmDb's full migration run intermittently exceeds Jest's default
// 5s beforeAll timeout on slower CI runners; raise it.
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('email_queue').del();
await db('events').del();
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
it('401s without an admin token', async () => {
const res = await request(app).get('/api/admin/events');
expect(res.status).toBe(401);
});
describe('POST /', () => {
it('creates an event, mints slug + share link and persists the row', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'wedding',
event_name: 'Smoke Wedding',
event_date: '2026-09-01',
// Field requirements default to ON (getEventFieldRequirements)
// so customer + admin contact data must be supplied.
customer_name: 'Client Person',
customer_email: 'client@example.com',
admin_email: 'admin@example.com',
require_password: false,
is_draft: true,
});
expect(res.status).toBe(200);
expect(res.body.id).toBeDefined();
expect(res.body.slug).toContain('wedding-smoke-wedding');
expect(typeof res.body.share_link).toBe('string');
expect(res.body.is_draft).toBe(true);
const row = await db('events').where({ id: res.body.id }).first();
expect(row).toBeDefined();
expect(row.event_name).toBe('Smoke Wedding');
expect(row.created_by).toBe(adminId);
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
// Draft creates must NOT queue the gallery_created email.
const queued = await db('email_queue').where({ event_id: res.body.id });
expect(queued).toHaveLength(0);
});
it('400s on an invalid event type', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'not-a-real-type',
event_name: 'Broken',
require_password: false,
});
expect(res.status).toBe(400);
expect(Array.isArray(res.body.errors)).toBe(true);
});
});
describe('GET /', () => {
it('lists events with pagination metadata and photo counts', async () => {
await insertEvent(db, adminId, { event_name: 'Alpha' });
await insertEvent(db, adminId, { event_name: 'Beta' });
const res = await auth(request(app).get('/api/admin/events'));
expect(res.status).toBe(200);
expect(res.body.events).toHaveLength(2);
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
for (const ev of res.body.events) {
expect(ev.photo_count).toBe(0);
}
});
});
describe('GET /:id', () => {
it('returns the event with photo/view stats', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
const res = await auth(request(app).get(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.event_name).toBe('Detail Event');
expect(res.body.photo_count).toBe(0);
expect(res.body.total_views).toBe(0);
expect(res.body.total_downloads).toBe(0);
expect(Array.isArray(res.body.recent_photos)).toBe(true);
});
it('404s for an unknown event id', async () => {
const res = await auth(request(app).get('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
describe('PUT /:id', () => {
it('updates mutable fields and persists them', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Before' });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
event_name: 'After',
welcome_message: 'Hello guests',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('After');
expect(row.welcome_message).toBe('Hello guests');
});
it('404s when updating a missing event', async () => {
const res = await auth(request(app).put('/api/admin/events/999999')).send({
event_name: 'Ghost',
});
expect(res.status).toBe(404);
});
});
describe('DELETE /:id', () => {
it('cascade-deletes the event row', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.message).toMatch(/deleted/i);
const row = await db('events').where({ id }).first();
expect(row).toBeUndefined();
});
it('404s when deleting a missing event', async () => {
const res = await auth(request(app).delete('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
});
+345
View File
@@ -0,0 +1,345 @@
/**
* HTTP-level tests for the admin TOTP MFA feature (#738).
*
* Two surfaces:
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
* /api/admin/auth (src/routes/adminAuth.js).
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
* (src/routes/auth.js, mounted /api/auth).
*
* Uses the same real-SQLite harness as the CRM route tests
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
* generated in-test via otplib's authenticator against the secret the
* /setup endpoint returns in plaintext.
*
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
* first require of db.js — mirror adminCrmAuth.test.js exactly.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
// tests don't need a token. Be explicit so a leaked env can't flip it on.
delete process.env.RECAPTCHA_SECRET_KEY;
const request = require('supertest');
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
jest.setTimeout(60000);
let db;
let cleanup;
let adminApp; // /api/admin/auth (enrollment)
let authApp; // /api/auth (login challenge)
/**
* Seed a bare admin (password known) and return its id + login creds.
* seedMinimal always creates username 'tester'; we need distinct rows per
* scenario, so insert directly with a unique username/email.
*/
async function seedAdmin({ username, superAdmin = false } = {}) {
const password = 'correct-horse';
const passwordHash = await bcrypt.hash(password, 4);
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
const row = {
username: uname,
email: `${uname}@example.com`,
password_hash: passwordHash,
must_change_password: false,
is_active: true,
created_at: new Date(),
};
if (superAdmin) {
const role = await db('roles').where({ name: 'super_admin' }).first();
if (!role) throw new Error('super_admin role not seeded');
row.role_id = role.id;
}
const inserted = await db('admin_users').insert(row).returning('id');
const id = inserted[0]?.id ?? inserted[0];
return { id, username: uname, password };
}
/** Run the full setup→enable enrollment against the live app. Returns
* the plaintext TOTP secret (for later login codes) and recovery codes. */
async function enroll(adminId) {
const token = mintAdminToken(adminId);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(setup.status).toBe(200);
const secret = setup.body.secret;
const enable = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(enable.status).toBe(200);
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.secret).toEqual(expect.any(String));
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
// Not yet enabled: status must still report disabled.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
// And the row stores an encrypted secret (not the plaintext one).
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeTruthy();
expect(row.two_factor_secret).not.toBe(res.body.secret);
expect(Number(row.two_factor_enabled)).toBe(0);
});
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
const admin = await seedAdmin();
const { recoveryCodes, token } = await enroll(admin.id);
expect(Array.isArray(recoveryCodes)).toBe(true);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.status).toBe(200);
expect(status.body.enabled).toBe(true);
expect(status.body.recoveryCodesRemaining).toBe(10);
expect(status.body.enrolledAt).toBeTruthy();
});
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
const valid = authenticator.generate(setup.body.secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: wrong });
expect(res.status).toBe(400);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
});
it('enable before setup is rejected', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '123456' });
// No provisional secret → ValidationError (400).
expect(res.status).toBe(400);
});
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
expect(noToken.status).toBe(401);
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
expect(setup.status).toBe(401);
});
// Regression guard for #735: super_admin used to be blocked from enrolling.
// Enrollment operates on req.admin.id and is role-agnostic — assert a
// super_admin can complete the full setup→enable flow.
it('#735 regression — a super_admin can enroll in MFA', async () => {
const admin = await seedAdmin({ superAdmin: true });
const { recoveryCodes, token } = await enroll(admin.id);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(true);
});
});
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
it('requires a valid code; a wrong code is rejected and state persists', async () => {
const admin = await seedAdmin();
const { token } = await enroll(admin.id);
const bad = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '000000' });
expect(bad.status).toBe(400);
const stillOn = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(stillOn.body.enabled).toBe(true);
});
it('a valid TOTP disables MFA and clears the stored secret', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(res.status).toBe(200);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
expect(status.body.recoveryCodesRemaining).toBe(0);
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
});
});
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBe(true);
expect(res.body.mfaToken).toEqual(expect.any(String));
expect(res.body.user).toBeUndefined(); // no completed session
// No admin auth cookie should have been set on the challenge response.
const cookies = res.headers['set-cookie'] || [];
expect(cookies.join(';')).not.toMatch(/adminToken/i);
});
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
const admin = await seedAdmin();
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBeUndefined();
expect(res.body.user).toBeDefined();
expect(res.body.user.username).toBe(admin.username);
});
it('login/mfa with a valid TOTP completes the session', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const { mfaToken } = challenge.body;
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken, code: authenticator.generate(secret) });
expect(res.status).toBe(200);
expect(res.body.user).toBeDefined();
expect(res.body.user.id).toBe(admin.id);
});
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const valid = authenticator.generate(secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
expect(res.status).toBe(401);
expect(res.body.code).toBe('MFA_INVALID');
expect(res.body.user).toBeUndefined();
});
it('a recovery code logs in and is then single-use (second use fails)', async () => {
const admin = await seedAdmin();
const { recoveryCodes } = await enroll(admin.id);
const recovery = recoveryCodes[0];
// First challenge + recovery-code exchange succeeds.
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const first = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c1.body.mfaToken, code: recovery });
expect(first.status).toBe(200);
expect(first.body.user).toBeDefined();
// recoveryCodesRemaining dropped by one.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
expect(status.body.recoveryCodesRemaining).toBe(9);
// Second use of the SAME recovery code must fail.
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const second = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c2.body.mfaToken, code: recovery });
expect(second.status).toBe(401);
expect(second.body.code).toBe('MFA_INVALID');
});
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,127 @@
/**
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
* the gallery password.
*
* POST /auth/gallery/share-login validates only the share token. For a
* password-protected gallery it previously minted a full `type:'gallery'`
* access token on the share token alone, letting anyone holding the share URL
* read the gallery without the password. The fix: when the gallery requires a
* password, return `{ requires_password: true }` with NO token and NO cookie.
*/
const express = require('express');
const request = require('supertest');
process.env.JWT_SECRET = 'share-login-test-secret';
const events = [];
jest.mock('../../src/database/db', () => {
function dbFn(table) {
if (table === 'events') {
let filter = () => true;
return {
where(criteria) {
filter = (row) => Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() { return events.find(filter); },
};
}
return { where() { return this; }, async first() { return undefined; } };
}
dbFn.raw = async () => {};
return { db: dbFn, logActivity: async () => {} };
});
// Share token is stored plainly on the fake event row.
jest.mock('../../src/services/shareLinkService', () => ({
getEventShareToken: (event) => event.share_token,
resolveShareIdentifier: async () => ({ event: null }),
}));
const mockSetGalleryAuthCookies = jest.fn();
jest.mock('../../src/utils/tokenUtils', () => ({
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
clearGalleryAuthCookies: jest.fn(),
getGalleryTokenFromRequest: jest.fn(),
setAdminAuthCookies: jest.fn(),
}));
jest.mock('../../src/utils/authSecurity', () => ({
trackFailedAttempt: jest.fn(async () => {}),
trackSuccessfulLogin: jest.fn(async () => {}),
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
resetLockout: jest.fn(async () => {}),
}));
// Collaborators the router imports at load but the share-login path doesn't hit.
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
jest.mock('../../src/services/mfaService', () => ({}));
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
const SHARE_TOKEN = 'a'.repeat(64);
beforeEach(() => {
events.length = 0;
mockSetGalleryAuthCookies.mockClear();
});
describe('POST /auth/gallery/share-login password enforcement', () => {
it('does NOT mint a token for a password-protected gallery', async () => {
events.push({
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(res.body.requires_password).toBe(true);
expect(res.body.token).toBeUndefined();
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
it('mints a token for a public (no-password) gallery', async () => {
events.push({
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.event).toBeDefined();
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
});
it('rejects a wrong share token regardless of password setting', async () => {
events.push({
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
});
const res = await request(makeApp())
.post('/auth/gallery/share-login')
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
expect(res.status).toBe(401);
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
/**
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
* /api/events router.
*
* The legacy router exposed create/list/update/delete/extend guarded by
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
* back-office account — down to a read-only viewer — could read every gallery's
* password_hash/share_token and take over any gallery. The fix removes that
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
* canonical /api/admin/events mount, where it inherits the permission +
* ownership guards.
*
* This test pins two invariants:
* 1. The legacy source file is gone (nothing can re-mount it).
* 2. The migrated extend route enforces ownership — a non-owning editor gets
* 403, the owner succeeds.
*/
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-legacy-acl-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-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, ownerId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Owner Gallery',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
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: ownerId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
it('the legacy events router source file no longer exists', () => {
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
});
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
let db; let cleanup; let app;
let ownerId; let ownerToken;
let editorId; let editorToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: ownerId } = await seedMinimal(db));
await assignAdminRole(db, ownerId, 'super_admin');
ownerToken = mintAdminToken(ownerId);
// A second, non-owning account with the low-trust editor role.
[editorId] = await db('admin_users').insert({
username: 'editor1', email: 'editor1@example.com',
password_hash: 'x', is_active: 1,
}).returning('id');
editorId = editorId?.id ?? editorId;
await assignAdminRole(db, editorId, 'editor');
editorToken = mintAdminToken(editorId);
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(); });
it('lets the owner extend their own gallery', async () => {
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 10 });
expect(res.status).toBe(200);
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
});
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
const id = await insertEvent(db, ownerId); // owned by the super_admin
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ days: 30 });
expect(res.status).toBe(403); // requireEventOwnership blocks it
});
it('validates the days field', async () => {
const id = await insertEvent(db, ownerId);
const res = await request(app)
.post(`/api/admin/events/${id}/extend`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ days: 9999 });
expect(res.status).toBe(400);
});
});
});
@@ -0,0 +1,153 @@
/**
* HTTP route tests for backend/src/routes/publicContracts (P0 — #570).
*
* Four endpoints on the customer-facing surface:
* GET /:token — load contract for signing
* POST /:token/sign — in-browser canvas signature submission
* POST /:token/upload-signed-pdf — wet-signed PDF upload
* GET /:token/pdf — download the contract PDF
*
* Tests pin the publicTokenGuards.loadActionToken contract per
* endpoint and a few endpoint-specific shape assertions. Deeper
* service-layer behaviour (PDF generation, signature attachment,
* integrity-hash compute) is covered by the contractService unit
* tests; here we only assert the HTTP contract.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubcontracts-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
describe('publicContracts routes', () => {
let db;
let cleanup;
let app;
let customerId;
let contractId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('contracts').insert({
contract_number: 'K-TEST-0001',
customer_account_id: customerId,
title: 'Test Booking Confirmation',
issue_date: new Date().toISOString().slice(0, 10),
status: 'sent',
language: 'de',
created_at: new Date().toISOString(),
}).returning('id');
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) tokenGuards._internal.badAttempts.clear();
});
describe('GET /:token', () => {
it('returns 404 for an unknown well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}`);
expect(res.status).toBe(404);
});
it('rejects malformed tokens with 400 before reaching the guard', async () => {
const res = await request(app).get('/api/public/contracts/short');
expect(res.status).toBe(400);
});
it('returns 410 for an expired token', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
it('returns 200 with the contract payload for a valid token', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(200);
expect(res.body.contract).toBeDefined();
});
});
describe('POST /:token/sign', () => {
it('rejects missing required fields (name, accepted) with 400', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app)
.post(`/api/public/contracts/${token}/sign`)
.send({}); // missing name + accepted
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on sign', async () => {
const fakeToken = 'b'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/sign`)
.send({ name: 'Jane Doe', accepted: true });
// Either 404 (token not found) or service-level error mapped to
// 4xx — what matters is the request didn't slip past validation.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
});
describe('POST /:token/upload-signed-pdf', () => {
it('rejects malformed tokens with 400 before multer runs', async () => {
const res = await request(app)
.post('/api/public/contracts/bad-token/upload-signed-pdf')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(400);
});
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/upload-signed-pdf`)
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(404);
});
});
describe('GET /:token/pdf', () => {
it('returns 404 for an unknown token on PDF download', async () => {
const fakeToken = 'd'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}/pdf`);
expect(res.status).toBe(404);
});
it('returns 410 for an expired token on PDF download', async () => {
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}/pdf`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
});
});
@@ -0,0 +1,106 @@
/**
* HTTP route tests for backend/src/routes/publicPaymentCheck (P0 — #570).
*
* Two endpoints:
* GET /:token — load invoice payment-check view
* POST /:token — record customer's "paid / unpaid / partial" claim
*
* Unlike the quote / contract public routes, payment-check goes
* through invoiceService rather than the shared publicTokenGuards.
* Tests focus on the validator gates and the unknown-token edge.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paymentcheck-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
describe('publicPaymentCheck routes', () => {
let cleanup;
let app;
beforeAll(async () => {
let db;
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('GET /:token', () => {
it('rejects malformed tokens with 400', async () => {
const res = await request(app).get('/api/public/payment-check/short');
expect(res.status).toBe(400);
});
it('returns a service-level error for an unknown well-formed token (4xx, not 500)', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/payment-check/${fakeToken}`);
// Service throws NotFound or similar — what matters is the
// request reaches the service AND isn't an unhandled 500.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(600);
});
});
describe('POST /:token', () => {
it('rejects malformed tokens with 400', async () => {
const res = await request(app)
.post('/api/public/payment-check/short')
.send({ action: 'paid_full' });
expect(res.status).toBe(400);
});
it('rejects an invalid action with 400', async () => {
const validToken = 'b'.repeat(64);
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action: 'maybe' });
expect(res.status).toBe(400);
});
it('accepts the canonical four actions through the validator', async () => {
// Each action passes validator (token is well-formed); service
// then rejects unknown token with a 4xx — what we're pinning is
// the validator doesn't reject any of the canonical actions.
const validToken = 'c'.repeat(64);
for (const action of ['paid_full', 'paid_with_skonto', 'partial', 'unpaid']) {
// eslint-disable-next-line no-await-in-loop
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action });
// Either succeeds (rare — no real invoice) or service-level
// 4xx for unknown token. Must NOT be 400 (which would mean
// the validator rejected the action).
expect(res.status).not.toBe(400);
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(600);
}
});
it('rejects negative amountMinor with 400', async () => {
// Validator chain: optional({ values: 'falsy' }) means
// amountMinor=0 / null / undefined gets skipped (allowed). For
// any actually-supplied integer, isInt({ min: 1 }) takes over —
// pin the negative-rejection so a future refactor can't loosen
// the lower bound silently.
const validToken = 'd'.repeat(64);
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action: 'partial', amountMinor: -100 });
expect(res.status).toBe(400);
});
});
});
@@ -0,0 +1,171 @@
/**
* HTTP route tests for backend/src/routes/publicQuotes (P0 — #570).
*
* Public token guards (publicTokenGuards.loadActionToken) are the most
* security-sensitive surface in the CRM module — these are the routes
* a customer hits via the link in the quote email, reachable from any
* IP with the raw token. A regression here means leaked tokens become
* permanently usable, or worse, an expired token starts working again.
*
* Tests pin the contract documented in publicTokenGuards.js:
* - 404 on unknown token (and IP bad-attempt counter ticks)
* - 410 on expired token
* - 410 on NULL expiry (defensive — historical bug)
* - 429 after 20 invalid attempts from one IP
* - 200 + sanitised payload on valid token
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
// MUST set the test DB env BEFORE the first require of anything that
// pulls in db.js — knexfile reads TEST_DATABASE_PATH at module-init
// time. The helper's bootCrmDb also has to be called once per file
// because the db module is cached; calling it from a second describe
// would silently reuse (or kill) the first instance's connection pool.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubquotes-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
describe('publicQuotes routes', () => {
let db;
let cleanup;
let app;
let customerId;
let quoteId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('quotes').insert({
quote_number: 'Q-TEST-0001',
customer_account_id: customerId,
currency: 'CHF',
issue_date: new Date().toISOString().slice(0, 10),
net_amount_minor: 10000,
vat_amount_minor: 0,
total_amount_minor: 10000,
status: 'sent',
language: 'de',
created_at: new Date(),
}).returning('id');
quoteId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Clear the in-memory IP bad-attempts map between scenarios so the
// lockout test starts from a known state — and so it doesn't bleed
// 429s into the unrelated tests that follow.
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) {
tokenGuards._internal.badAttempts.clear();
}
});
describe('GET /:token', () => {
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/quotes/${fakeToken}`);
expect(res.status).toBe(404);
expect(res.body.error).toBeTruthy();
});
it('rejects malformed (non-64-hex) tokens with 400', async () => {
const res = await request(app).get('/api/public/quotes/not-a-real-token');
expect(res.status).toBe(400);
});
it('returns 410 for a token whose expires_at is in the past', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
// The NULL-expiry guard in loadActionToken is intentionally
// defensive but the current schema declares
// quote_action_tokens.expires_at NOT NULL — so the defensive
// branch is unreachable at the route level. Test it directly
// against loadActionToken in a unit suite if you want coverage.
it('returns 200 with a sanitised quote payload for a valid token', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(200);
expect(res.body.quote).toBeDefined();
// API uses camelCase on the public view (see publicQuoteView in
// the route handler).
expect(res.body.quote.quoteNumber).toBe('Q-TEST-0001');
// Internal IDs / admin metadata must NOT appear on the public payload
expect(res.body.quote.customer_account_id).toBeUndefined();
expect(res.body.quote.customerAccountId).toBeUndefined();
expect(res.body.quote.createdByAdminId).toBeUndefined();
});
it('locks the IP after 20 invalid token lookups (429 TOKEN_LOOKUP_LOCKED)', async () => {
const fakeToken = 'b'.repeat(64);
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
const r = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(r.status).toBe(404);
}
const locked = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(locked.status).toBe(429);
expect(locked.body.code).toBe('TOKEN_LOOKUP_LOCKED');
}, 30000);
});
describe('POST /:token/respond', () => {
it('rejects an invalid action (must be accept|decline) with 400', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', { quote_id: quoteId });
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'maybe' });
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on respond', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/quotes/${fakeToken}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(404);
});
it('returns 410 when the token has expired (service-side check)', async () => {
// The POST path goes through quoteService.recordResponse rather
// than loadActionToken, so the error shape can differ from the
// GET expiry response — what matters is the HTTP status.
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(410);
});
});
});
@@ -0,0 +1,203 @@
/**
* HTTP route tests for the ADMIN Live Slideshow endpoints:
* POST /api/admin/events/:id/slideshow/generate
* POST /api/admin/events/:id/slideshow/disable
* PATCH /api/admin/events/:id/slideshow
* PUT /api/admin/settings/slideshow (global preset + watermark + fit)
*
* Pins the contracts + the two regressions hit during the build:
* - the events table has NO `updated_at` column, so these writes must NOT set
* it (else every call 500s — that was the original "Generate" failure);
* - the `slideshow` feature flag gates these endpoints (403 when off);
* - PUT /admin/settings/slideshow validates + clamps every key.
*/
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-show-admin-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
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 Live Slideshow endpoints', () => {
let db; let cleanup; let app; let adminId; let token;
// Match slideshowPublic.test.js — bootCrmDb's full migration run intermittently
// exceeds Jest's default 5s `beforeAll` timeout on slower CI runners; raise
// it so this doesn't block PRs.
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'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
// 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 });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await setFlag(db, 'slideshow', true);
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
describe('generate / disable', () => {
it('mints a share token (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(typeof res.body.show_share_token).toBe('string');
expect(res.body.show_share_token).toHaveLength(64);
expect(res.body.slideshow_url).toContain(`/show/${res.body.show_share_token}`);
const row = await db('events').where({ id }).first();
expect(row.show_share_token).toBe(res.body.show_share_token);
});
it('regenerate rotates the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'old-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(res.body.show_share_token).not.toBe('old-token');
});
it('disable nulls the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'live-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/disable`));
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_share_token == null).toBe(true);
});
it('403 when the slideshow feature is off', async () => {
const id = await insertEvent(db, adminId);
await setFlag(db, 'slideshow', false);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(403);
});
it('401 without an admin token', async () => {
const id = await insertEvent(db, adminId);
const res = await request(app).post(`/api/admin/events/${id}/slideshow/generate`);
expect(res.status).toBe(401);
});
});
describe('PATCH /:id/slideshow', () => {
it('persists display + watermark mode (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({
show_interval_ms: 9000,
show_transition: 'cut',
show_transition_ms: 300,
show_watermark: true,
show_colorfilter: 'bw',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_interval_ms).toBe(9000);
expect(row.show_transition).toBe('cut');
expect(row.show_transition_ms).toBe(300);
expect(row.show_colorfilter).toBe('bw');
expect(row.show_watermark === 1 || row.show_watermark === true).toBe(true);
});
it('show_watermark=null sets the column to NULL (inherit global)', async () => {
const id = await insertEvent(db, adminId, { show_watermark: 1 });
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_watermark: null });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_watermark == null).toBe(true);
});
it('400 on an invalid transition', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_transition: 'wormhole' });
expect(res.status).toBe(400);
});
});
describe('PUT /api/admin/settings/slideshow', () => {
const getSetting = async (key) => {
const row = await db('app_settings').where({ setting_key: key }).first();
return row ? JSON.parse(row.setting_value) : undefined;
};
it('persists the global preset + watermark + fit, clamping out-of-range values', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'contain',
slideshow_interval_ms: 9000,
slideshow_transition: 'slide',
slideshow_transition_ms: 250,
slideshow_colorfilter: 'sepia',
slideshow_watermark_enabled: true,
slideshow_watermark_opacity: 999, // clamp -> 100
slideshow_watermark_size: 99, // clamp -> 40
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('contain');
expect(await getSetting('slideshow_interval_ms')).toBe(9000);
expect(await getSetting('slideshow_transition')).toBe('slide');
expect(await getSetting('slideshow_transition_ms')).toBe(250);
expect(await getSetting('slideshow_colorfilter')).toBe('sepia');
expect(await getSetting('slideshow_watermark_enabled')).toBe(true);
expect(await getSetting('slideshow_watermark_opacity')).toBe(100);
expect(await getSetting('slideshow_watermark_size')).toBe(40);
});
it('coerces an invalid fit / transition to the safe default', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'banana',
slideshow_transition: 'wormhole',
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('cover');
expect(await getSetting('slideshow_transition')).toBe('crossfade');
});
});
});
@@ -0,0 +1,286 @@
/**
* HTTP route tests for the PUBLIC Live Slideshow surface (backend/src/routes/gallery.js):
* GET /:slug/show/:token/state (cheap settings + photo-count poll)
* GET /:slug/show/:token/session (mints the gallery JWT + cookie)
*
* These pin the two pieces of logic where real bugs lived during the build:
* - resolveSlideshow: the `slideshow` feature flag is a MASTER kill-switch
* (404 when off), plus token / expiry / draft / archived / inactive guards.
* - slideshowSettings: the watermark cascade (global look + per-event on/off),
* image fit, and the fact that globals are read from `app_settings`
* (regression for the getSetting→nonexistent-`settings`-table bug).
*/
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-show-pub-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
const SLUG = 'wedding-test';
const TOKEN = 'show-tok-abcdef';
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
async function setSetting(db, key, value, type = 'slideshow') {
await db('app_settings').where({ setting_key: key }).del();
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: type, updated_at: new Date() });
}
async function insertEvent(db, over = {}) {
const base = {
slug: SLUG,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/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,
show_share_token: TOKEN,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('public Live Slideshow routes', () => {
let db; let cleanup; let app;
// bootCrmDb runs the full migration set against a fresh SQLite file, which
// takes <2s locally but has been observed to exceed Jest's default 5s
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
// block PRs on CI; doesn't affect happy-path local runs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = express();
app.use(express.json());
app.use(cookieParser());
// Both routers mount under /api/gallery in production; the display-only
// guard lives on download routes (gallery) + the feedback POST (galleryFeedback).
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
// 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 });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await db('feature_flags').del();
invalidateFeatureFlagCache();
invalidateSlideshowGlobals();
await setFlag(db, 'slideshow', true);
});
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
describe('resolveSlideshow guards', () => {
it('200 + per-event display settings on a live link', async () => {
await insertEvent(db, {
show_interval_ms: 8000,
show_transition: 'kenburns',
show_transition_ms: 1200,
show_colorfilter: 'sepia',
});
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
interval_ms: 8000,
transition: 'kenburns',
transition_ms: 1200,
colorfilter: 'sepia',
fit: 'cover',
photo_count: 0,
watermark: null,
});
});
it('404 when the slideshow feature flag is OFF (master kill-switch)', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 on an unknown token', async () => {
await insertEvent(db);
const res = await request(app).get(stateUrl('not-the-token'));
expect(res.status).toBe(404);
});
it('404 when the share token is null (link never minted / disabled)', async () => {
await insertEvent(db, { show_share_token: null });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event has expired', async () => {
await insertEvent(db, { expires_at: new Date(Date.now() - 1000).toISOString() });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is a draft', async () => {
await insertEvent(db, { is_draft: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is archived', async () => {
await insertEvent(db, { is_archived: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
});
describe('slideshowSettings — image fit (global, live)', () => {
it('reflects the global slideshow_fit setting', async () => {
await insertEvent(db);
await setSetting(db, 'slideshow_fit', 'contain');
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body.fit).toBe('contain');
});
});
describe('slideshowSettings — watermark cascade (global look + per-event on/off)', () => {
async function enableGlobalWatermark() {
await setSetting(db, 'slideshow_watermark_enabled', true);
await setSetting(db, 'slideshow_watermark_source', 'logo');
await setSetting(db, 'slideshow_watermark_position', 'top-left');
await setSetting(db, 'slideshow_watermark_opacity', 40);
await setSetting(db, 'slideshow_watermark_style', 'original');
await setSetting(db, 'slideshow_watermark_size', 9);
await setSetting(db, 'branding_logo_url', '/uploads/logos/light.svg', 'branding');
}
it('inherits the global watermark when show_watermark is NULL', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toEqual({
url: '/uploads/logos/light.svg',
position: 'top-left',
opacity: 40,
style: 'original',
size: 9,
});
});
it('resolves the dark logo / favicon sources', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_source', 'favicon');
await setSetting(db, 'branding_favicon_url', '/uploads/favicons/f.png', 'branding');
const res = await request(app).get(stateUrl());
expect(res.body.watermark.url).toBe('/uploads/favicons/f.png');
});
it('per-event OFF override hides the watermark even when the global is on', async () => {
await insertEvent(db, { show_watermark: 0 });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
it('per-event ON override shows the watermark even when the global is off', async () => {
await insertEvent(db, { show_watermark: 1 });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_enabled', false);
const res = await request(app).get(stateUrl());
expect(res.body.watermark).not.toBeNull();
expect(res.body.watermark.url).toBe('/uploads/logos/light.svg');
});
it('null when enabled but no logo URL is configured', async () => {
await insertEvent(db, { show_watermark: null });
await setSetting(db, 'slideshow_watermark_enabled', true);
// no branding_logo_url set
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
});
describe('display-only token guards (#646 review concern 1)', () => {
// Mint a real slideshow JWT, then prove it is denied on the
// download / upload / feedback routes (display-only contract).
async function slideshowJwt() {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
return res.body.token;
}
it('403 on whole-gallery download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on single-photo download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download/1`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on bulk download-selected', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/download-selected`).set('Authorization', `Bearer ${jwt}`).send({ photoIds: [1] });
expect(res.status).toBe(403);
});
it('403 on feedback POST', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/photos/1/feedback`).set('Authorization', `Bearer ${jwt}`).send({ feedback_type: 'like' });
expect(res.status).toBe(403);
});
});
describe('GET /session', () => {
it('mints a token + sets the gallery cookie on a valid link', async () => {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(20);
expect(res.body.event).toMatchObject({ event_name: 'Test Wedding' });
expect(res.body).toHaveProperty('settings');
expect(res.body).toHaveProperty('photo_count', 0);
expect(res.headers['set-cookie']).toBeDefined();
});
it('404 when the feature is off', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(404);
});
});
});
@@ -0,0 +1,219 @@
/**
* Verifies the backup-integrity check covers every CRM document
* artefact column and correctly buckets each row into:
* - verifiedOk — file exists AND hash matches (when hash is stored)
* - missing — `*_path` set but file is not on disk
* - hashMismatches — file exists but bytes don't hash to `*_sha256`
* - existsButNoHash — file exists, no `*_sha256` column for this row
*
* Uses the CRM integration harness (bootCrmDb) so the schema +
* STORAGE_PATH wiring exactly mirrors production behaviour.
*
* Background: this service is the diagnostic for the
* `storage/business-docs/` gap fixed in the same PR — without it,
* a restored install would have audit-trail columns referencing
* files that no longer exist, but admins would have no way to see
* the breakage until a customer asked for their contract back.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
jest.setTimeout(30000);
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
let db;
let cleanup;
let customerId;
let storagePath;
let backupIntegrityService;
function seedFile(relPath, content) {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return { abs, relPath, sha: sha256(content) };
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
storagePath = process.env.STORAGE_PATH;
backupIntegrityService = require('../../src/services/backupIntegrityService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
// Wipe CRM rows between tests so each scenario sees a clean slate.
// Order matters: child tables before parents.
await db('invoice_line_items').del().catch(() => {});
await db('invoice_payment_log').del().catch(() => {});
await db('invoices').del().catch(() => {});
await db('quote_line_items').del().catch(() => {});
await db('quotes').del().catch(() => {});
await db('contracts').del().catch(() => {});
});
it('returns an empty report when no documents reference any path', async () => {
const report = await backupIntegrityService.verifyDocumentArtefacts();
expect(report.summary.totalRows).toBe(0);
expect(report.summary.verifiedOk).toBe(0);
expect(report.missing).toEqual([]);
expect(report.hashMismatches).toEqual([]);
expect(report.existsButNoHash).toEqual([]);
expect(report.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(report.scopes).toEqual(expect.arrayContaining(['quote', 'contract', 'contract-signature', 'invoice']));
});
it('flags a contract whose signed_pdf_path file is missing', async () => {
// Reference a file that we deliberately never create on disk.
// knex's `.returning('id')` returns `[{ id: N }]` on Postgres and
// newer SQLite, but `[N]` (plain int) on some SQLite versions —
// unwrap both shapes the same way the crmDb test harness does.
const inserted = await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-MISSING',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-2026-MISSING.pdf',
created_at: new Date(),
}).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
const hit = report.missing.find((m) => m.rowId === contractId);
expect(hit).toMatchObject({
table: 'contracts',
column: 'signed_pdf_path',
expectedPath: 'business-docs/contract/2026/C-2026-MISSING.pdf',
});
expect(report.summary.missingFiles).toBe(1);
});
it('verifies a contract whose file exists AND hash matches', async () => {
const { relPath, sha } = seedFile(
'business-docs/contract/2026/C-2026-OK.pdf',
'this is the signed contract content',
);
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-OK',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_pdf_path: relPath,
signed_pdf_sha256: sha,
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
expect(report.summary.verifiedOk).toBeGreaterThanOrEqual(1);
expect(report.summary.missingFiles).toBe(0);
expect(report.summary.hashMismatches).toBe(0);
});
it('flags a hash mismatch when the file exists but bytes differ from signed_pdf_sha256', async () => {
const { relPath } = seedFile(
'business-docs/contract/2026/C-2026-TAMPER.pdf',
'tampered bytes on disk',
);
const inserted = await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-TAMPER',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_pdf_path: relPath,
// Hash for completely different content — simulates tampering or
// bit-rot between sign-time and now.
signed_pdf_sha256: sha256('the ORIGINAL bytes the customer signed'),
created_at: new Date(),
}).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
const hit = report.hashMismatches.find((m) => m.rowId === contractId);
expect(hit).toBeDefined();
expect(hit.expectedSha).not.toBe(hit.actualSha);
expect(hit.column).toBe('signed_pdf_path');
});
it('buckets signature PNGs into existsButNoHash (no hash column)', async () => {
const { relPath } = seedFile(
'business-docs/contract/signatures/99/customer-1700000000000.png',
'\x89PNG\r\n\x1a\n', // doesn't have to be a real PNG, just bytes
);
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-SIG',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_customer_signature_path: relPath,
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({
scope: ['contract-signature'],
});
expect(report.summary.existsButNoHash).toBeGreaterThanOrEqual(1);
expect(report.summary.verifiedOk).toBe(0); // no hash → not "verified ok"
expect(report.summary.missingFiles).toBe(0);
const hit = report.existsButNoHash.find((r) => r.column === 'signed_customer_signature_path');
expect(hit).toBeDefined();
});
it('respects the scope filter — contract scope skips quote/invoice tables', async () => {
// Seed an invoice with a missing pdf_path AND a contract with a
// missing signed_pdf_path. Scoping to contract should only flag
// the contract.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'INV-2026-SCOPE',
status: 'sent',
pdf_path: 'business-docs/invoice/2026/INV-2026-SCOPE.pdf',
issue_date: '2026-01-01',
due_date: '2026-01-31',
created_at: new Date(),
});
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-2026-SCOPE.pdf',
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
expect(report.scopes).toEqual(['contract']);
expect(report.missing.every((m) => m.table === 'contracts')).toBe(true);
expect(report.missing.some((m) => m.table === 'invoices')).toBe(false);
});
it('covers invoices.imported_pdf_path (admin-uploaded historical scans)', async () => {
// Imported invoices are the most catastrophic case — there's no
// renderer that can reproduce them. Verifier must check this column
// alongside invoices.pdf_path.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'IMP-2025-001',
status: 'sent',
imported_pdf_path: 'business-docs/invoice-imports/2025/legacy.pdf',
issue_date: '2025-06-01',
due_date: '2025-07-01',
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['invoice'] });
const hit = report.missing.find((m) => m.column === 'imported_pdf_path');
expect(hit).toBeDefined();
});
});
@@ -0,0 +1,105 @@
/**
* Unit tests for the recipient resolver that routes invoice / Storno /
* reminder emails to a bookkeeper address when one is configured,
* while keeping the decision-maker (primary email) on CC.
*
* Pure helper, no DB, no side effects.
*/
const { resolveBillingRecipients } = require('../../src/services/_billingRecipients');
describe('resolveBillingRecipients', () => {
it('routes to the primary email when no billing_email is set', () => {
expect(resolveBillingRecipients({ email: 'bride@example.com' }, null))
.toEqual({ to: 'bride@example.com', cc: undefined });
});
it('routes to billing_email and CCs the primary when both are set', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
billing_email: 'books@example.com',
}, null)).toEqual({
to: 'books@example.com',
cc: ['bride@example.com'],
});
});
it('folds the per-document cc_pdf_email into the CC list', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
billing_email: 'books@example.com',
}, 'advisor@example.com')).toEqual({
to: 'books@example.com',
cc: ['bride@example.com', 'advisor@example.com'],
});
});
it('uses cc_pdf_email alone when there is no billing_email', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
}, 'advisor@example.com')).toEqual({
to: 'bride@example.com',
cc: ['advisor@example.com'],
});
});
it('does not CC the primary onto itself when billing_email equals email', () => {
expect(resolveBillingRecipients({
email: 'same@example.com',
billing_email: 'same@example.com',
}, null)).toEqual({
to: 'same@example.com',
cc: undefined,
});
});
it('is case-insensitive when deduping addresses', () => {
// RFC 5321 says mailbox local-parts MAY be case sensitive, but in
// practice every mail server treats them as insensitive — and the
// admin entering "BRIDE@example.com" in one field and
// "bride@example.com" in another should not produce two copies.
expect(resolveBillingRecipients({
email: 'BRIDE@example.com',
billing_email: 'books@example.com',
}, 'bride@example.com')).toEqual({
to: 'books@example.com',
cc: ['BRIDE@example.com'],
});
});
it('trims whitespace around the addresses', () => {
expect(resolveBillingRecipients({
email: ' bride@example.com ',
billing_email: ' books@example.com\n',
}, '\tadvisor@example.com ')).toEqual({
to: 'books@example.com',
cc: ['bride@example.com', 'advisor@example.com'],
});
});
it('treats empty-string billing_email as not set', () => {
expect(resolveBillingRecipients({
email: 'bride@example.com',
billing_email: '',
}, null)).toEqual({
to: 'bride@example.com',
cc: undefined,
});
});
it('returns an empty To when neither email nor billing_email is set', () => {
// Caller is responsible for surfacing this — emailProcessor's own
// validation will reject the empty recipient. The helper just
// refuses to crash.
expect(resolveBillingRecipients({}, null))
.toEqual({ to: '', cc: undefined });
});
it('tolerates a null customer without throwing', () => {
// Per-doc cc alone is never promoted to To: — it stays
// supplemental. A missing customer is a caller bug; we just refuse
// to crash and let emailProcessor reject the empty recipient.
expect(resolveBillingRecipients(null, 'a@b.com'))
.toEqual({ to: '', cc: undefined });
});
});
@@ -0,0 +1,52 @@
const path = require('path');
const os = require('os');
const fs = require('fs').promises;
// Point storage at a throwaway temp dir before requiring the service so the
// module-level getStoragePath() picks it up if evaluated.
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
const chunkedUpload = require('../../src/services/chunkedUploadService');
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
afterAll(async () => {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
});
it('strips directory-traversal components from the stored filename', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: '../../uploads/logos/evil.svg',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
const meta = chunkedUpload.getUploadStatus(uploadId);
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
expect(meta.filename).toBe('evil.svg');
});
it('keeps a normal filename intact', async () => {
const { uploadId } = await chunkedUpload.initializeUpload({
filename: 'clip.mp4',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
});
expect(uploadId).toBeTruthy();
});
it('rejects a filename that collapses to nothing', async () => {
await expect(
chunkedUpload.initializeUpload({
filename: '../',
fileSize: 10,
mimeType: 'video/mp4',
eventId: 1,
totalChunks: 1,
})
).rejects.toThrow(/Invalid filename/);
});
});
@@ -0,0 +1,109 @@
/**
* Unit tests for the pure helpers in contractService (migration 130).
*
* The DB-bound CRUD paths (createContract / sendContract /
* recordCustomerSignature / attachSignedPdfUpload) are exercised in
* manual QA via the admin + public routes. This file covers the
* deterministic helpers so regressions in placeholder substitution or
* section ordering surface before they leak into a rendered contract.
*
* The service pulls in DB-bound peers (businessProfileService,
* pdfService, emailProcessor) at the top level. We stub the DB layer
* + the side-effect peers so the require chain doesn't try to connect
* to anything; the helpers under test are pure.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'contractService');
jest.mock('../../src/database/db', () => ({
db: jest.fn(),
logActivity: jest.fn(),
withRetry: (fn) => fn(),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(),
}));
jest.mock('../../src/services/pdfService', () => ({
renderContractToBuffer: jest.fn(),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(),
}));
const { _internal } = require(servicePath);
const { renderTemplatedBody, SECTIONS_ORDER } = _internal;
describe('renderTemplatedBody', () => {
it('substitutes simple {{var}} placeholders', () => {
expect(renderTemplatedBody(
'Hello {{name}}, due in {{net_days}} days.',
{ name: 'Alice', net_days: 30 },
)).toBe('Hello Alice, due in 30 days.');
});
it('preserves unknown placeholders literally so admins notice missing fields', () => {
expect(renderTemplatedBody(
'Bill from {{issuer}} to {{customer_name}}',
{ issuer: 'PicPeak GmbH' },
)).toBe('Bill from PicPeak GmbH to {{customer_name}}');
});
it('keeps {{#if var}}…{{/if}} block when var is truthy', () => {
expect(renderTemplatedBody(
'{{#if has_skonto}}Skonto: {{pct}} %{{/if}} on early payment',
{ has_skonto: true, pct: 2 },
)).toBe('Skonto: 2 % on early payment');
});
it('drops {{#if var}}…{{/if}} block when var is falsy', () => {
expect(renderTemplatedBody(
'Net {{net_days}} d{{#if has_skonto}}, Skonto {{pct}}%{{/if}}.',
{ net_days: 30, has_skonto: false, pct: 2 },
)).toBe('Net 30 d.');
});
it('treats missing variables in {{#if}} as falsy', () => {
expect(renderTemplatedBody(
'A{{#if missing}}B{{/if}}C',
{ unrelated: 'foo' },
)).toBe('AC');
});
it('handles empty strings and missing variables map gracefully', () => {
expect(renderTemplatedBody('', { x: 1 })).toBe('');
expect(renderTemplatedBody('plain text', null)).toBe('plain text');
expect(renderTemplatedBody('plain text', undefined)).toBe('plain text');
});
it('passes through non-string input unchanged', () => {
expect(renderTemplatedBody(null, { x: 1 })).toBeNull();
expect(renderTemplatedBody(undefined, { x: 1 })).toBeUndefined();
});
it('substitutes numeric and falsy variable values as strings', () => {
expect(renderTemplatedBody('count: {{n}}', { n: 0 })).toBe('count: 0');
expect(renderTemplatedBody('flag: {{flag}}', { flag: false })).toBe('flag: false');
});
});
describe('SECTIONS_ORDER', () => {
it('matches the canonical six-section order locked in the spec', () => {
expect(SECTIONS_ORDER).toEqual([
'basics', 'scope', 'privacy', 'commercial', 'nda', 'closing',
]);
});
it('stays in sync with contractBlocksService.ALLOWED_SECTIONS', () => {
const blocksService = require('../../src/services/contractBlocksService');
expect([...SECTIONS_ORDER].sort()).toEqual(
[...blocksService.ALLOWED_SECTIONS].sort(),
);
});
});
@@ -0,0 +1,100 @@
/**
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
*
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
* tuned for tracker scripts — defence-in-depth, even though the field is
* admin-only.
*/
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
describe('sanitizeTrackerSnippet (#663)', () => {
test('returns empty string for non-string / empty / whitespace input', () => {
expect(sanitizeTrackerSnippet(null)).toBe('');
expect(sanitizeTrackerSnippet(undefined)).toBe('');
expect(sanitizeTrackerSnippet(42)).toBe('');
expect(sanitizeTrackerSnippet('')).toBe('');
expect(sanitizeTrackerSnippet(' ')).toBe('');
});
test('passes through a Plausible-style script tag with data-domain', () => {
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://plausible.io/js/script.js"');
expect(out).toContain('data-domain="example.com"');
expect(out).toContain('defer');
});
test('passes through a Umami-style script with data-website-id', () => {
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://analytics.example.com/script.js"');
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
});
test('passes through inline script body unchanged', () => {
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('window.GA = "x"');
expect(out).toContain('console.log("init")');
});
test('allows <noscript> fallback', () => {
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('<noscript>');
});
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
const out = sanitizeTrackerSnippet(
'<link rel="preconnect" href="https://t.example.com">'
+ '<link rel="dns-prefetch" href="https://t.example.com">',
);
expect(out).toContain('rel="preconnect"');
expect(out).toContain('rel="dns-prefetch"');
expect(out).toContain('href="https://t.example.com"');
});
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
expect(out).not.toContain('stylesheet');
expect(out).not.toContain('href');
});
test('strips disallowed tags entirely', () => {
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('iframe');
expect(out).not.toContain('<div');
expect(out).not.toContain('<h1');
});
test('strips javascript: URLs from script src', () => {
const input = '<script src="javascript:alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('javascript:');
});
test('strips data: URLs from script src', () => {
const input = '<script src="data:text/javascript,alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('data:text/javascript');
});
test('strips on* event-handler attributes (defence-in-depth)', () => {
// event-handler attrs are not in our allowlist; sanitize-html strips them.
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('onload');
expect(out).toContain('src="https://t.example/x.js"');
});
test('returns empty string on unparseable input rather than throwing', () => {
// sanitize-html is fault-tolerant — pass deliberately malformed and
// confirm we don't blow up.
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
});
});
@@ -0,0 +1,213 @@
/**
* Tests for the passive-customer surface:
*
* - createDirect inserts a customer with password_hash=null,
* queueEmail is never called, race-guard rejects duplicates
* - createInvitation allows passing through when the existing
* customer is passive (promotion path); still rejects when the
* existing customer is active (real duplicate)
* - acceptInvitation upserts into an existing passive customer
* row (preserving id) when one exists; inserts a fresh row
* otherwise; still rejects when the existing customer is active
*
* Pure unit tests — db is mocked via a thenable chain so we can
* inspect every insert / update payload without spinning up SQLite.
*/
// ----- mock db chain --------------------------------------------------
//
// We need fine-grained control over which row each table-name returns
// for `.first()`, what `.insert(...).returning('id')` resolves to, and
// what `.update(...)` resolves to. The chain is a thenable proxy that
// terminates on the call we care about.
const tableSeeds = {}; // table → first-row return value
const insertResults = {}; // table → array of inserted rows (auto-id from a counter)
const updateCalls = []; // [{ table, where, updates }]
let nextInsertId = 1000;
function resetMockDb() {
for (const k of Object.keys(tableSeeds)) delete tableSeeds[k];
for (const k of Object.keys(insertResults)) delete insertResults[k];
updateCalls.length = 0;
nextInsertId = 1000;
}
function makeChain(tableName) {
const chain = {
_whereClauses: [],
where(...args) { this._whereClauses.push(args); return this; },
whereNull() { return this; },
whereNot() { return this; },
andWhere() { return this; },
orderBy() { return this; },
leftJoin() { return this; },
groupBy() { return this; },
select(...args) {
// listCustomers / search → return seeded array
const seeded = tableSeeds[`${tableName}__select`];
return Promise.resolve(seeded || []);
},
first() {
const seeded = tableSeeds[tableName];
return Promise.resolve(seeded);
},
insert(payload) {
const id = nextInsertId++;
insertResults[tableName] = insertResults[tableName] || [];
insertResults[tableName].push({ ...payload, id });
const result = { id };
return {
returning() { return Promise.resolve([result]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); },
};
},
update(updates) {
updateCalls.push({ table: tableName, where: this._whereClauses, updates });
return Promise.resolve(1);
},
del() { return Promise.resolve(1); },
raw() { return this; },
};
return chain;
}
const mockDbFn = jest.fn((tableName) => makeChain(tableName));
mockDbFn.raw = jest.fn();
mockDbFn.transaction = async (cb) => cb(mockDbFn);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
const mockQueueEmail = jest.fn(async () => {});
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: mockQueueEmail,
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: { default_locale: 'de' },
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/frontendUrl', () => ({
getFrontendBaseUrl: jest.fn(async () => 'https://test.example'),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const customerAccountsService = require('../../src/services/customerAccountsService');
beforeEach(() => {
resetMockDb();
mockQueueEmail.mockClear();
});
// --------------------------------------------------------------------
// createDirect
// --------------------------------------------------------------------
describe('createDirect', () => {
it('inserts a customer with password_hash=null, is_active=true', async () => {
tableSeeds.customer_accounts = undefined; // no duplicate
const result = await customerAccountsService.createDirect({
email: 'test@example.com',
prefill: { first_name: 'Anna', company_name: 'ACME GmbH' },
createdByAdminId: 5,
});
expect(result.id).toBeDefined();
const inserted = insertResults.customer_accounts[0];
expect(inserted.email).toBe('test@example.com');
expect(inserted.password_hash).toBeNull();
expect(inserted.created_by_admin_id).toBe(5);
expect(inserted.first_name).toBe('Anna');
expect(inserted.company_name).toBe('ACME GmbH');
// is_active should be truthy (could be 1 or true depending on formatBoolean impl)
expect([true, 1, '1']).toContain(inserted.is_active);
});
it('defaults preferred_language from the business profile', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: 'de@example.com',
prefill: {},
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('de');
});
it('honours preferred_language when the admin pre-fills it', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: 'fr@example.com',
prefill: { preferred_language: 'fr' },
createdByAdminId: 1,
});
expect(insertResults.customer_accounts[0].preferred_language).toBe('fr');
});
it('rejects when a customer with the email already exists', async () => {
tableSeeds.customer_accounts = { id: 7, email: 'dup@example.com', password_hash: 'whatever' };
await expect(customerAccountsService.createDirect({
email: 'dup@example.com',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/already exists/);
});
it('rejects when only an EMAIL is supplied without anything else (still valid)', async () => {
tableSeeds.customer_accounts = undefined;
await expect(customerAccountsService.createDirect({
email: '',
prefill: {},
createdByAdminId: 1,
})).rejects.toThrow(/Email is required/);
});
it('NEVER queues an invitation email (regression guard)', async () => {
tableSeeds.customer_accounts = undefined;
await customerAccountsService.createDirect({
email: 'silent@example.com',
prefill: {},
createdByAdminId: 1,
});
expect(mockQueueEmail).not.toHaveBeenCalled();
});
});
// --------------------------------------------------------------------
// createInvitation passive-allowance behaviour
// --------------------------------------------------------------------
describe('createInvitation — duplicate-email guard', () => {
it('still rejects when the existing customer has a password (real duplicate)', async () => {
tableSeeds.customer_accounts = { id: 1, email: 'active@example.com', password_hash: 'hash' };
await expect(customerAccountsService.createInvitation({
email: 'active@example.com',
invitedById: 5,
prefill: null,
})).rejects.toThrow(/already exists/);
expect(mockQueueEmail).not.toHaveBeenCalled();
});
it('ALLOWS through when the existing customer is passive (promote path)', async () => {
tableSeeds.customer_accounts = { id: 7, email: 'passive@example.com', password_hash: null };
// no pending invitation
// The chain returns `tableSeeds.customer_invitations` for .first()
// and we haven't seeded one, so it's undefined → allowed through.
const out = await customerAccountsService.createInvitation({
email: 'passive@example.com',
invitedById: 9,
prefill: { first_name: 'Anna' },
});
expect(out.id).toBeDefined();
expect(out.token).toMatch(/^[0-9a-f]{64}$/);
expect(mockQueueEmail).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,182 @@
/**
* Unit tests for the pure helpers in customerHoursService (migration
* 129). The CRUD paths themselves are exercised end-to-end via the
* admin/customers routes during manual QA; this file covers the
* deterministic logic so regressions in the rate / duration / lock
* resolution show up before they hit a real invoice.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
// The service imports invoiceService which pulls in the DB. We don't
// need either for the pure helpers — stub the DB layer so the
// require chain doesn't try to connect to anything.
jest.mock('../../src/database/db', () => ({
db: jest.fn(),
logActivity: jest.fn(),
withRetry: (fn) => fn(),
}));
jest.mock('../../src/services/invoiceService', () => ({}));
const { _internal } = require(servicePath);
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
describe('computeDurationMinutes', () => {
it('returns minute count for a basic window', () => {
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
});
it('handles single-minute precision', () => {
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
});
it('rejects malformed input', () => {
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
});
it('rejects zero or negative duration', () => {
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
});
});
describe('resolveEffectiveRate', () => {
it('prefers the per-entry override when set', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: 20000 },
{ hourly_rate_minor: 15000 },
)).toBe(20000);
});
it('falls back to the customer default when no override', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
)).toBe(15000);
});
it('throws when override, customer rate, AND install default are all unset', () => {
expect(() => resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
null,
)).toThrow(/No hourly rate/);
});
it('falls back to the install-wide default when override + customer rate are unset', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
12000,
)).toBe(12000);
});
it('customer rate wins over the install-wide default', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
12000,
)).toBe(15000);
});
it('treats override=0 as "explicitly zero" (not null)', () => {
// Override === 0 is unusual but legal — pro bono blocks, internal
// tracking. Must NOT fall through to the customer default.
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: 0 },
{ hourly_rate_minor: 15000 },
)).toBe(0);
});
});
describe('isEntryLocked', () => {
it('unbilled entry → not locked', () => {
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
});
it('monthly draft → not locked (still accumulating)', () => {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
)).toBe(false);
});
it('standalone draft with no send time → not locked', () => {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
)).toBe(false);
});
it('future-scheduled draft → not locked', () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
)).toBe(false);
});
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
)).toBe(true);
});
it('sent / paid / overdue / cancelled → locked', () => {
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
expect(isEntryLocked(
{ invoice_id: 42 },
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
)).toBe(true);
}
});
it('entry references a deleted invoice (null) → treat as unbilled', () => {
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
});
});
describe('buildLineItemFromEntry', () => {
const baseEntry = {
entry_date: '2026-05-20',
start_time: '09:00',
end_time: '11:30',
duration_minutes: 150,
description: 'Editing wedding photos',
};
it('formats the description per spec', () => {
const li = buildLineItemFromEntry(baseEntry, 15000);
expect(li.description).toBe('2026-05-20 09:0011:30 (2.50h): Editing wedding photos');
});
it('omits the colon when no description', () => {
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
expect(li.description).toBe('2026-05-20 09:0011:30 (2.50h)');
});
it('quantity is decimal hours with 2 places', () => {
const li = buildLineItemFromEntry(baseEntry, 15000);
expect(li.quantity).toBeCloseTo(2.5, 5);
});
it('line_total rounds correctly for non-clean durations', () => {
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
const li = buildLineItemFromEntry(
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
10000,
);
expect(li.line_total_minor).toBe(2500);
});
it('zero-rate line items produce a zero total without exploding', () => {
const li = buildLineItemFromEntry(baseEntry, 0);
expect(li.line_total_minor).toBe(0);
expect(li.unit_price_minor).toBe(0);
});
});
@@ -0,0 +1,119 @@
/**
* Unit tests for `normaliseEventTimeTriple` — the pure validator that
* gates the migration-137 calendar time columns on events.
*
* The DB-bound CRUD paths (createEvent/updateEvent) inline this
* helper and write through hasColumnCached guards; those are
* exercised in manual QA. This file pins the contract so a future
* tweak to the validation rules doesn't silently break it.
*/
const path = require('path');
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'eventService');
// Stub every DB-bound peer so the require chain doesn't try to open
// a knex connection. The helper under test is pure.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/schemaCache', () => ({ hasColumnCached: jest.fn() }));
jest.mock('bcrypt', () => ({ hash: jest.fn() }));
const { normaliseEventTimeTriple } = require(servicePath);
describe('normaliseEventTimeTriple', () => {
it('defaults to full-day when is_full_day is undefined', () => {
expect(normaliseEventTimeTriple({})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('forces times to null when is_full_day is true even if times are supplied', () => {
expect(normaliseEventTimeTriple({
is_full_day: true,
event_time_start: '10:00',
event_time_end: '12:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
it('accepts a valid timed range when is_full_day is false', () => {
expect(normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '09:30',
event_time_end: '17:00',
})).toEqual({
event_time_start: '09:30',
event_time_end: '17:00',
is_full_day: false,
});
});
it('throws when is_full_day is false and start is missing/malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '25:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '9:00',
event_time_end: '12:00',
})).toThrow(/HH:MM/);
});
it('throws when end is missing or malformed', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
})).toThrow(/HH:MM/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '12:99',
})).toThrow(/HH:MM/);
});
it('throws when end is at or before start', () => {
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '10:00',
event_time_end: '10:00',
})).toThrow(/after/);
expect(() => normaliseEventTimeTriple({
is_full_day: false,
event_time_start: '15:00',
event_time_end: '10:00',
})).toThrow(/after/);
});
it('parses string boolean flag', () => {
// `parseBooleanInput` accepts "true" / "false" / "1" / "0" — verify
// the helper consumes them transparently.
expect(normaliseEventTimeTriple({
is_full_day: 'false',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: '08:00',
event_time_end: '09:00',
is_full_day: false,
});
expect(normaliseEventTimeTriple({
is_full_day: '1',
event_time_start: '08:00',
event_time_end: '09:00',
})).toEqual({
event_time_start: null,
event_time_end: null,
is_full_day: true,
});
});
});
@@ -0,0 +1,160 @@
/**
* Unit tests for the accounting money logic — re-bill markup (incoming
* invoices) and internal-expense amount/build. Pure functions via _internal.
*/
const expenseService = require('../../src/services/expenseService');
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
describe('computeMarkupMinor', () => {
it('percent of base, rounded', () => {
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
});
it('flat / none', () => {
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
});
});
describe('resolveMarkup precedence (no contract / no DB)', () => {
it('override > source clause', async () => {
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
});
it("source clause when no override", async () => {
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
});
it('none when nothing set', async () => {
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
});
});
describe('computeExpenseAmount', () => {
it('mileage / per-diem = quantity x rate, rounded', () => {
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
});
it('amount = the entered minor amount', () => {
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
});
it('null when quantity or rate missing', () => {
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
});
});
describe('buildExpenseInsert (internal expense)', () => {
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
expect(row.kind).toBe('amount');
expect(row.disposition).toBe('eigener_aufwand');
expect(row.tax_treatment).toBe('domestic');
expect(row.status).toBe('open');
expect(row.chf_amount_minor).toBe(5000);
expect(row.created_by_admin_id).toBe(7);
expect(row.inbound_document_id).toBeNull();
});
it('mileage uses the override rate, else the settings km rate', () => {
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
expect(withDefault.rate_minor).toBe(70);
expect(withDefault.chf_amount_minor).toBe(2940);
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
expect(withOverride.rate_minor).toBe(100);
expect(withOverride.chf_amount_minor).toBe(4200);
});
it('per_diem uses days x per-diem rate', () => {
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
expect(row.rate_minor).toBe(8000);
expect(row.chf_amount_minor).toBe(16000);
});
it('event_id null = booked to company; proof path carried', () => {
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
expect(company.event_id).toBeNull();
expect(company.receipt_path).toBe('/p/x.pdf');
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
expect(evt.event_id).toBe(9);
});
});
describe('buildInboundLineItem (re-bill line)', () => {
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
expect(li.unit_price_minor).toBe(11000);
expect(li.line_total_minor).toBe(11000);
expect(li.quantity).toBe(1);
expect(li.description).toBe('ACME (Weiterverrechnung)');
});
it('passthrough: distinct suffix, no markup passes through at cost', () => {
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
expect(li.unit_price_minor).toBe(5000);
expect(li.description).toBe('SBB (Durchlaufende Position)');
});
it('falls back to net amount + generic label when total/supplier missing', () => {
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
expect(li.unit_price_minor).toBe(7300);
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
});
it('throws when there is no amount to re-bill', () => {
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
.toThrow(/no amount/i);
});
});
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
const reclaim = ['CH', 'LI'];
it('explicit valid treatment always wins', () => {
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
});
it('country in the reclaim list → domestic', () => {
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
});
it('country outside the reclaim list → foreign non-reclaimable', () => {
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
});
it('unknown / empty country falls back to domestic', () => {
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
});
it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
});
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
});
});
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
const future = new Date(Date.now() + 86400000).toISOString();
const past = new Date(Date.now() - 86400000).toISOString();
it('monthly draft and not-yet-armed scheduled are mutable', () => {
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
});
it('armed / issued invoices are locked', () => {
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
});
});
@@ -0,0 +1,122 @@
/**
* Tests for the migration-119 hierarchy support in invoiceService —
* the shared helpers come from quoteService._internal (validated in
* quoteService.hierarchy.test.js), so we focus here on the
* invoice-specific seams:
*
* - quote → invoice cloner preserves parent_position + details_text
* across the conversion
* - the cloner's installment "adjustment" line only reconciles
* against TOP-LEVEL cloned items (sub-items don't contribute to
* net so they can't appear in the sum)
*
* Pure helper, no DB.
*/
const quoteService = require('../../src/services/quoteService');
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
describe('quote → invoice cloner shape', () => {
// Models the in-memory transformation step from `scheduleInvoicesForEvent`:
// take source quote line items (with parent_position) and produce the
// `cloned` array that's passed into insertLineItemsHierarchical.
function modelCloner(sourceLines) {
return sourceLines.map((li) => ({
position: parseInt(li.position, 10),
quantity: Number(li.quantity || 1),
description: li.description,
unit_price_minor: parseInt(li.unit_price_minor, 10) || 0,
discount_percent: Number(li.discount_percent || 0),
line_total_minor: parseInt(li.line_total_minor, 10) || 0,
parent_position: li.parent_position == null ? null : parseInt(li.parent_position, 10),
details_text: li.details_text || null,
}));
}
it('preserves parent_position so the hierarchy carries across conversion', () => {
const source = [
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
];
const cloned = modelCloner(source);
expect(cloned[0].parent_position).toBeNull();
expect(cloned[1].parent_position).toBe(1);
expect(cloned[2].parent_position).toBe(1);
// The cloned shape passes hierarchy validation — same positions
// means the same parent links work without any remap.
expect(() => validateLineItemHierarchy(cloned)).not.toThrow();
});
it('preserves details_text verbatim', () => {
const source = [
{ position: 1, description: 'P', unit_price_minor: 0, line_total_minor: 0, parent_position: null,
details_text: 'Includes online gallery + 100 high-res downloads.' },
];
const cloned = modelCloner(source);
expect(cloned[0].details_text).toBe('Includes online gallery + 100 high-res downloads.');
});
it('installment adjustment reconciles against TOP-LEVEL cloned items only', () => {
// Recreate the inner math from scheduleInvoicesForEvent: sum
// only line_total_minor where parent_position is null. Sub-items
// would otherwise double-count and skew the adjustment.
//
// Note: the cloner stores raw line_total_minor on each row from
// the source quote. By the time this sum runs, the parent's
// line_total_minor has already been resolved upstream (via
// computeTotals on the quote at save time) — so iterating
// top-level only sums the resolved parent totals + standalone
// top-level items. Sub-items never contribute here regardless of
// whether their parent's total was auto-resolved or not.
const cloned = modelCloner([
// Parent — resolved line_total assumed to be €450 (sum of priced sub-items below)
{ position: 1, unit_price_minor: 0, line_total_minor: 45000, parent_position: null },
// Sub-items €150 + €200 + €100 — shown for transparency, must
// NOT enter the reconciliation sum.
{ position: 2, unit_price_minor: 15000, line_total_minor: 15000, parent_position: 1 },
{ position: 3, unit_price_minor: 20000, line_total_minor: 20000, parent_position: 1 },
{ position: 4, unit_price_minor: 10000, line_total_minor: 10000, parent_position: 1 },
// Another top-level €100
{ position: 5, unit_price_minor: 10000, line_total_minor: 10000, parent_position: null },
]);
const clonedSum = cloned
.filter((x) => x.parent_position == null)
.reduce((s, x) => s + x.line_total_minor, 0);
// Top-level only: 45000 (resolved parent) + 10000 = 55000. NOT 100000.
expect(clonedSum).toBe(55000);
});
});
describe('insertLineItemsHierarchical for invoices', () => {
function makeTrxMock() {
let nextId = 200;
const inserts = [];
const trx = (tableName) => ({
insert(row) {
const id = nextId++;
inserts.push({ table: tableName, row: { ...row, id } });
return {
returning() { return Promise.resolve([{ id }]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); },
};
},
});
return { trx, inserts };
}
it('handles invoice_line_items with the same two-phase + remap logic', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 7, [
{ position: 1, description: 'Parent', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Sub A', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
]);
expect(inserts).toHaveLength(2);
expect(inserts.every((i) => i.table === 'invoice_line_items')).toBe(true);
expect(inserts.every((i) => i.row.invoice_id === 7)).toBe(true);
// Parent inserted first, sub-item second with parent_line_item_id
// matching the parent's synthesised id.
expect(inserts[0].row.parent_line_item_id).toBeNull();
expect(inserts[1].row.parent_line_item_id).toBe(200);
});
});
@@ -0,0 +1,329 @@
/**
* Tests for invoiceService.updateInstallmentPlan + validateInstallmentPlanInput.
*
* Validation tests run against the pure validator directly. Orchestration
* tests use the same deep-mocked db pattern as invoiceService.locks.test.js
* — chains are queued per table and assertions probe insert/update/delete
* call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('validateInstallmentPlanInput', () => {
const { validateInstallmentPlanInput } = invoiceService;
it('throws on empty array', () => {
expect(() => validateInstallmentPlanInput([]))
.toThrow(/non-empty array/);
});
it('throws on non-array', () => {
expect(() => validateInstallmentPlanInput(null))
.toThrow(/non-empty array/);
});
it('throws on out-of-range percent', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 150, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
expect(() => validateInstallmentPlanInput([
{ percent: -5, trigger: 'quote_accepted', offset_days: 0 },
])).toThrow(/percent must be between 0 and 100/);
});
it('throws on unknown trigger', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 100, trigger: 'on_friday', offset_days: 0 },
])).toThrow(/invalid trigger/);
});
it('throws when percents do not sum to 100', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 50, trigger: 'before_event', offset_days: -7 },
])).toThrow(/must sum to 100/);
});
it('accepts a valid three-row plan with mixed triggers', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'Anzahlung' },
{ percent: 40, trigger: 'before_event', offset_days: -14, label: 'Zwischenrechnung' },
{ percent: 30, trigger: 'after_delivery', offset_days: 0, label: 'Schlussrechnung' },
])).not.toThrow();
});
it('tolerates 0.001 rounding drift in the sum', () => {
expect(() => validateInstallmentPlanInput([
{ percent: 33.333, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 33.333, trigger: 'before_event', offset_days: -7 },
{ percent: 33.334, trigger: 'after_event', offset_days: 0 },
])).not.toThrow();
});
});
describe('updateInstallmentPlan — guards', () => {
beforeEach(() => resetChains());
const goodPlan = [
{ percent: 50, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 50, trigger: 'before_event', offset_days: -14, label: 'B' },
];
it('rejects when dealUuid is missing', async () => {
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: '', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the deal has no invoices', async () => {
pickChainFor('invoices')._selectResult = [];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 404 });
});
it('400s + NOT_INSTALLMENT_PLAN on a single-invoice deal', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 1, status: 'scheduled', kind: 'invoice' },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'NOT_INSTALLMENT_PLAN' });
});
it('409s + INVOICE_LOCKED when any sibling has already shipped', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'sent', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'INVOICE_LOCKED' });
});
it('409s + PLAN_HAS_STORNO when the deal contains a Storno', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, deal_uuid: 'deal-1', installment_total: 2, installment_index: 0,
status: 'scheduled', kind: 'storno', invoice_number: 'S-2026-0001',
net_amount_minor: -5000, vat_amount_minor: -385, total_amount_minor: -5385, shipping_amount_minor: 0 },
{ id: 2, deal_uuid: 'deal-1', installment_total: 2, installment_index: 1,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0002',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, shipping_amount_minor: 0 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: goodPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 409, code: 'PLAN_HAS_STORNO' });
});
it('rejects an invalid plan (percents not summing to 100) before opening the txn', async () => {
const badPlan = [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0 },
{ percent: 30, trigger: 'before_event', offset_days: -7 },
];
await expect(invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', installments: badPlan, adminId: 1,
})).rejects.toMatchObject({ statusCode: 400, code: 'PERCENT_SUM_INVALID' });
});
});
describe('updateInstallmentPlan — reshape (smoke)', () => {
beforeEach(() => resetChains());
const sibling = (overrides) => ({
id: 0, deal_uuid: 'deal-1', installment_total: 3, installment_index: 0,
status: 'scheduled', kind: 'invoice', invoice_number: 'R-2026-0001',
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
shipping_amount_minor: 0, vat_rate: 7.7,
customer_account_id: 5, source_quote_id: null, event_id: null,
event_name: 'Wedding', event_date: '2026-08-15',
language: 'de', currency: 'CHF',
issue_date: '2026-05-25', due_date: '2026-06-24',
cc_pdf_email: null,
payment_net_days_template_id: null, payment_timing_template_id: null,
payment_term_snapshot: null,
...overrides,
});
it('keeps invoice_numbers and does not claim new sequence on 3→3 reshape', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 20, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 50, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2, 3]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([]);
// Sequence helper never touched on a same-count reshape.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('grows 2→3 by claiming one new invoice_number and keeping the first two', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
installment_total: 2 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('invoices')._insertResult = [{ id: 99 }];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 30, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 30, trigger: 'before_event', offset_days: -14, label: 'B' },
{ percent: 40, trigger: 'after_event', offset_days: 7, label: 'C' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created.length).toBe(1);
expect(result.deleted).toEqual([]);
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
});
it('shrinks 3→2 by deleting the third row + its line items', async () => {
pickChainFor('invoices')._selectResult = [
sibling({ id: 1, installment_index: 0, invoice_number: 'R-2026-0001',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 2, installment_index: 1, invoice_number: 'R-2026-0002',
net_amount_minor: 3000, vat_amount_minor: 231, total_amount_minor: 3231 }),
sibling({ id: 3, installment_index: 2, invoice_number: 'R-2026-0003',
net_amount_minor: 4000, vat_amount_minor: 308, total_amount_minor: 4308 }),
];
pickChainFor('customer_accounts')._firstValue = { id: 5, is_active: 1, feature_bills: 1 };
pickChainFor('invoice_line_items')._selectResult = [];
const result = await invoiceService.updateInstallmentPlan({
trx: mockDbFn, dealUuid: 'deal-1', adminId: 42,
installments: [
{ percent: 40, trigger: 'quote_accepted', offset_days: 0, label: 'A' },
{ percent: 60, trigger: 'after_event', offset_days: 7, label: 'B' },
],
});
expect(result.kept).toEqual([1, 2]);
expect(result.created).toEqual([]);
expect(result.deleted).toEqual([3]);
// Line items + invoice rows deleted on the trimmed sibling.
expect(pickChainFor('invoice_line_items').del).toHaveBeenCalled();
expect(pickChainFor('invoices').del).toHaveBeenCalled();
});
});
@@ -0,0 +1,374 @@
/**
* Tests for invoiceService lock + state-transition guards.
*
* Focuses on the rules that protect tax/audit integrity:
* - reissueInvoice refuses to act on `scheduled` (use Edit)
* - reissueInvoice cancels + clones any other status
* - releaseForDelivery refuses to act on non-pending_delivery
* - recordPaymentCheckAction refuses already-used / expired tokens
*
* db is deep-mocked so the tests are deterministic and fast.
*/
// Mock db chain: each table call returns a builder whose methods
// chain (return `this`) until a terminal method (.first / .update /
// .insert / .returning) resolves with the queued value.
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
_allRows: [],
// knex chains are thenable — awaiting them runs the query and
// resolves with the row set. We mirror that so callers can
// `await trx('t').where(...).orderBy(...)` and get an array.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
// select is both chainable (`.select('col').first()`) and awaitable
// via the chain's `then` (`await q.select(...)` returns `_selectResult`).
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
// db.transaction(cb) runs the callback with a "trx" — for our
// purposes the same chain factory works as trx.
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('invoiceService.reissueInvoice', () => {
beforeEach(() => resetChains());
it('throws USE_EDIT_INSTEAD when the source is still scheduled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled' };
await expect(invoiceService.reissueInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('throws when the source invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.reissueInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('cancels the original and creates a new row when status is sent', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'sent', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
shipping_amount_minor: 0, cc_pdf_email: null,
business_bank_account_id: null, qr_format: null,
payment_term_template_id: null, event_id: null,
source_quote_id: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, is_active: 1, feature_bills: 1,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const result = await invoiceService.reissueInvoice(1, 42);
expect(result.id).toBeDefined();
expect(result.replaces).toBe(1);
});
});
describe('invoiceService.createStorno', () => {
beforeEach(() => resetChains());
it('rejects when the source invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.createStorno(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects when the source is still scheduled (drafts edit in place)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'USE_EDIT_INSTEAD' });
});
it('rejects when the source is already cancelled (no double-Storno)', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects when asked to Storno a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.createStorno(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('inserts a Storno row and flips the original on a sent invoice', async () => {
// Original is `sent`, no line items, no event.
const invoicesChain = pickChainFor('invoices');
invoicesChain._firstValue = {
id: 1, status: 'sent', kind: 'invoice', customer_account_id: 5,
currency: 'CHF', language: 'de', vat_rate: 7.7,
net_amount_minor: 30000, vat_amount_minor: 2310,
total_amount_minor: 32310, shipping_amount_minor: 0,
cc_pdf_email: null, event_id: null,
};
pickChainFor('invoice_line_items')._selectResult = [];
pickChainFor('app_settings')._firstValue = null;
// document_sequences row used by claimNextSequence.
pickChainFor('document_sequences')._firstValue = { current_value: 42 };
const stornoId = await invoiceService.createStorno(1, 42);
expect(stornoId).toBeDefined();
// The mock chain's .update() is called twice on `invoices`:
// 1) `.insert(...).returning('id')` for the Storno row
// 2) `.update({status:'cancelled', cancellation_storno_id})` on the original
// We just verify the helpers were exercised on the right table.
expect(invoicesChain.insert).toHaveBeenCalled();
expect(invoicesChain.update).toHaveBeenCalled();
// The Storno insert payload should carry kind='storno' and
// negated row-level totals. Inspect the first insert call's
// payload to confirm.
const insertedRow = invoicesChain.insert.mock.calls[0][0];
expect(insertedRow.kind).toBe('storno');
expect(insertedRow.net_amount_minor).toBe(-30000);
expect(insertedRow.vat_amount_minor).toBe(-2310);
expect(insertedRow.total_amount_minor).toBe(-32310);
expect(insertedRow.cancels_invoice_id).toBe(1);
expect(insertedRow.status).toBe('scheduled');
// No payment instrument on a Storno.
expect(insertedRow.business_bank_account_id).toBeNull();
expect(insertedRow.qr_format).toBeNull();
expect(insertedRow.payment_term_template_id).toBeNull();
// Storni have no real payment due, but the schema's NOT NULL
// constraint on due_date forces a value — we mirror issue_date.
expect(insertedRow.due_date).toBe(insertedRow.issue_date);
});
});
describe('invoiceService.cancelInvoice', () => {
beforeEach(() => resetChains());
it('rejects when the invoice does not exist (404)', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.cancelInvoice(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
it('rejects with ALREADY_CANCELLED when status is cancelled', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'cancelled', kind: 'invoice' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('rejects with IS_STORNO when asked to cancel a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent', kind: 'storno' };
await expect(invoiceService.cancelInvoice(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('soft-cancels a scheduled (draft) invoice without generating a Storno', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'scheduled', kind: 'invoice', event_id: null };
const result = await invoiceService.cancelInvoice(1, 42);
expect(result).toEqual({ cancelled: true, stornoId: null });
});
});
describe('invoiceService.releaseForDelivery', () => {
beforeEach(() => resetChains());
it('refuses when status is not pending_delivery', async () => {
pickChainFor('invoices')._firstValue = { id: 1, status: 'sent' };
await expect(invoiceService.releaseForDelivery(1, 42))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
it('404s when the invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
await expect(invoiceService.releaseForDelivery(999, 42))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe('invoiceService.recordPaymentCheckAction', () => {
beforeEach(() => resetChains());
it('rejects invalid actions', async () => {
await expect(invoiceService.recordPaymentCheckAction({
token: 'abc', action: 'foo',
})).rejects.toMatchObject({ statusCode: 400 });
});
it('404s when the token is not on file', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = null;
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 404 });
});
it('410s + TOKEN_ALREADY_USED when the row has used_at set', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: new Date(),
expires_at: new Date(Date.now() + 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_ALREADY_USED' });
});
it('410s + TOKEN_EXPIRED when the row is past expires_at', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() - 86400000),
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'unpaid',
})).rejects.toMatchObject({ statusCode: 410, code: 'TOKEN_EXPIRED' });
});
it('rejects partial with amount <= 0', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 10000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 0,
})).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects partial with amount > outstanding', async () => {
pickChainFor('invoice_payment_check_tokens')._firstValue = {
id: 1, used_at: null,
expires_at: new Date(Date.now() + 86400000),
};
pickChainFor('invoices')._firstValue = {
id: 5, total_amount_minor: 5000, paid_amount_minor: 0, late_fee_amount_minor: 0,
};
await expect(invoiceService.recordPaymentCheckAction({
token: 'a'.repeat(64), action: 'partial', amountMinor: 9999,
})).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('invoiceService.queuePaymentCheckEmail', () => {
beforeEach(() => resetChains());
it('skips when invoice does not exist', async () => {
pickChainFor('invoices')._firstValue = null;
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'not_found' });
});
it('skips when status is not sent/overdue', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'paid',
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res.sent).toBe(false);
expect(res.reason).toMatch(/wrong_status_paid/);
});
it('respects the 24h throttle', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
};
const res = await invoiceService.queuePaymentCheckEmail(1);
expect(res).toEqual({ sent: false, reason: 'throttled_24h' });
});
it('bypasses the throttle when skipThrottle=true', async () => {
pickChainFor('invoices')._firstValue = {
id: 1, status: 'overdue',
customer_account_id: 5,
created_by_admin_id: 42,
total_amount_minor: 10000,
currency: 'CHF',
language: 'de',
reminder_level: 0,
due_date: '2026-05-01',
last_payment_check_at: new Date(Date.now() - 3600 * 1000),
event_id: null,
};
pickChainFor('admin_users')._firstValue = { id: 42, email: 'admin@example.com', username: 'admin' };
pickChainFor('business_profile')._firstValue = null;
pickChainFor('customer_accounts')._firstValue = { id: 5, email: 'c@example.com', display_name: 'Test' };
const res = await invoiceService.queuePaymentCheckEmail(1, { skipThrottle: true });
expect(res.sent).toBe(true);
expect(res.token).toMatch(/^[a-f0-9]{64}$/);
});
});
@@ -0,0 +1,259 @@
/**
* Smoke tests for invoiceService's primary flows ahead of the god-file
* decomposition — createInvoice happy path (incl. the line-item
* totals/VAT math), list/get reads, and the status-transition guards
* on cancelInvoice / releaseForDelivery.
*
* Uses the same deep-mocked db pattern as
* invoiceService.installmentPlan.test.js — chains are queued per table
* and assertions probe insert/update call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
jest.clearAllMocks();
}
const activeCustomer = {
id: 5, is_active: 1, feature_bills: 1,
billing_cadence: 'per_event', preferred_language: 'de',
};
describe('createInvoice — happy path + totals', () => {
beforeEach(() => resetChains());
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
pickChainFor('invoices')._insertResult = [{ id: 777 }];
const result = await invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 8.1,
lineItems: [
// 2 × 100.00 = 200.00
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
// 50.00 with 10% discount = 45.00
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
// Parent header — total auto-resolves from priced sub-items (350.00)
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
],
}, 1);
expect(result.invoiceIds).toEqual([777]);
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
// double-count. VAT = round(59500 × 8.1%) = 4820.
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
invoice_number: 'R-2026-0042',
customer_account_id: 5,
currency: 'CHF',
status: 'scheduled',
net_amount_minor: 59500,
vat_rate: 8.1,
vat_amount_minor: 4820,
shipping_amount_minor: 0,
total_amount_minor: 64320,
installment_total: 1,
}));
// Exactly one sequence number claimed for a single-row create.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
// Line items landed in invoice_line_items.
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
});
it('409s on a deactivated customer before touching the sequence', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
await expect(invoiceService.createInvoice({
customerAccountId: 5, vatRate: 0, lineItems: [],
}, 1)).rejects.toMatchObject({ statusCode: 409 });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
await expect(invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 7.7,
lineItems: [
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
],
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
});
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
beforeEach(() => resetChains());
it('lists invoices with total + pagination echo', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, invoice_number: 'R-2026-0001' },
{ id: 2, invoice_number: 'R-2026-0002' },
];
pickChainFor('invoices')._firstValue = { total: 7 };
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
expect(result.rows).toHaveLength(2);
expect(result.total).toBe(7);
expect(result.page).toBe(2);
expect(result.pageSize).toBe(10);
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
});
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
pickChainFor('invoice_line_items as li')._selectResult = [
{ id: 30, position: 1, description: 'Shoot' },
];
pickChainFor('invoice_payment_log')._selectResult = [];
const result = await invoiceService.getInvoiceById(3);
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
expect(result.lineItems).toHaveLength(1);
expect(result.payments).toEqual([]);
});
it('getInvoiceById returns null for an unknown id', async () => {
pickChainFor('invoices')._firstValue = undefined;
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
});
});
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
beforeEach(() => resetChains());
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
};
const result = await invoiceService.cancelInvoice(9, 1);
expect(result).toEqual({ cancelled: true, stornoId: null });
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'cancelled' })
);
});
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'cancelled', kind: 'invoice',
};
await expect(invoiceService.cancelInvoice(9, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
pickChainFor('invoices')._firstValue = {
id: 10, status: 'sent', kind: 'storno',
};
await expect(invoiceService.cancelInvoice(10, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
pickChainFor('invoices')._firstValue = {
id: 11, status: 'sent', kind: 'invoice',
};
await expect(invoiceService.releaseForDelivery(11, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
});
@@ -0,0 +1,249 @@
/**
* Tests for ledgerService (Accounting Layer A).
*
* Two layers:
* 1. Pure helpers (rateKey, csvEscape, minorToDecimal).
* 2. buildPostings + exportPostings — db chain + appSettings mocked so we can
* feed canned invoices/inbound/expenses and assert the Buchungssätze +
* the per-tool CSV shapes.
*/
// ----- canned data per table ------------------------------------------
let accountsRows = [];
let vatRows = [];
let invoiceRows = [];
let inboundRows = [];
let expenseRows = [];
function makeChain(rows) {
const c = {
_rows: rows,
then(onR, onJ) { return Promise.resolve(this._rows).then(onR, onJ); },
leftJoin() { return this; },
where() { return this; },
whereNot() { return this; },
whereIn() { return this; },
whereNotIn() { return this; },
whereBetween() { return this; },
whereRaw() { return this; },
orderBy() { return this; },
orderByRaw() { return this; },
modify(cb) { if (typeof cb === 'function') cb(this); return this; },
select() { return Promise.resolve(this._rows); },
first() { return Promise.resolve(this._rows[0]); },
};
return c;
}
const mockDbFn = jest.fn((table) => {
switch (table) {
case 'ledger_accounts': return makeChain(accountsRows);
case 'vat_codes': return makeChain(vatRows);
case 'invoices': return makeChain(invoiceRows);
case 'inbound_documents': return makeChain(inboundRows);
case 'expenses': return makeChain(expenseRows);
default: return makeChain([]);
}
});
mockDbFn.raw = (s) => s;
mockDbFn.schema = {
hasTable: jest.fn(async () => true),
hasColumn: jest.fn(async () => true),
};
jest.mock('../../src/database/db', () => ({ db: mockDbFn, withRetry: async (fn) => fn() }));
const SETTINGS = {
ledger_account_debitoren: '1100',
ledger_account_kreditoren: '2000',
ledger_account_default_revenue: '3400',
ledger_account_default_expense: '6700',
ledger_account_mileage: '6200',
ledger_account_per_diem: '6640',
ledger_account_rebilled_revenue: '3940',
ledger_vat_map: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' },
ledger_output_vat_map: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' },
};
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async (key, def) => (key in SETTINGS ? SETTINGS[key] : def)),
}));
const ledgerService = require('../../src/services/ledgerService');
const { rateKey, csvEscape, minorToDecimal } = ledgerService._internal;
beforeEach(() => {
accountsRows = [
{ id: 1, number: '1100', name: 'Debitoren', type: 'asset' },
{ id: 2, number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
{ id: 3, number: '2000', name: 'Kreditoren', type: 'liability' },
{ id: 4, number: '6570', name: 'Informatikaufwand', type: 'expense' },
{ id: 5, number: '6200', name: 'Fahrzeugaufwand', type: 'expense' },
{ id: 6, number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
];
vatRows = [{ id: 9, code: 'UN81', rate: 8.1, direction: 'output', account_id: null }];
invoiceRows = [];
inboundRows = [];
expenseRows = [];
});
// ----- pure helpers ----------------------------------------------------
describe('rateKey', () => {
it('normalises rate to the output-map key', () => {
expect(rateKey(8.1)).toBe('8.1');
expect(rateKey(8.10)).toBe('8.1');
expect(rateKey('2.60')).toBe('2.6');
expect(rateKey(0)).toBe('0');
expect(rateKey(null)).toBe('0');
});
});
describe('csvEscape / minorToDecimal', () => {
it('quotes + doubles inner quotes', () => {
expect(csvEscape('a,b')).toBe('"a,b"');
expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""');
expect(csvEscape(null)).toBe('""');
});
it('renders minor units as 2dp', () => {
expect(minorToDecimal(10810)).toBe('108.10');
expect(minorToDecimal(0)).toBe('0.00');
expect(minorToDecimal(null)).toBe('0.00');
});
});
// ----- buildPostings ---------------------------------------------------
describe('buildPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
it('books a revenue invoice as Dr Debitoren / Cr Ertrag with the output VAT code', async () => {
invoiceRows = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1,
net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '1100', debitName: 'Debitoren',
creditAccount: '3400', creditName: 'Dienstleistungsertrag',
grossMinor: 10810, netMinor: 10000, vatMinor: 810,
vatCode: 'UN81', source: 'revenue', eventName: 'Wedding A',
});
});
it('books an incoming invoice as Dr Aufwand(category) / Cr Kreditoren with the input VAT code', async () => {
inboundRows = [{
id: 5, invoice_number: 'L-77', invoice_date: '2026-01-12', created_at: '2026-01-13 09:00:00',
supplier_name: 'Lab AG', tax_treatment: 'domestic',
net_amount_minor: 2000, vat_amount_minor: 162, total_amount_minor: 2162,
event_id: 7, cat_account_id: 4, event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6570', creditAccount: '2000',
grossMinor: 2162, netMinor: 2000, vatMinor: 162,
vatCode: 'VST81', source: 'incoming', eventName: 'Wedding A',
});
});
it('falls back to the kind default account for a category-less mileage expense', async () => {
expenseRows = [{
id: 9, created_at: '2026-02-01 12:00:00', kind: 'mileage', supplier_name: null, description: 'Drive',
tax_treatment: 'foreign_vat_non_reclaimable', event_id: null,
original_amount_minor: null, chf_amount_minor: 5000,
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null,
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6200', creditAccount: '2000',
grossMinor: 5000, vatMinor: 0,
vatCode: 'VST00', source: 'expense', eventName: '',
});
});
it('sorts the combined journal chronologically across all sources', async () => {
invoiceRows = [{ id: 1, invoice_number: 'R1', issue_date: '2026-02-20', vat_rate: 8.1, net_amount_minor: 100, vat_amount_minor: 8, total_amount_minor: 108, customer_company_name: 'A' }];
inboundRows = [{ id: 5, invoice_number: 'L1', invoice_date: '2026-01-05', created_at: '2026-01-05', supplier_name: 'Lab', tax_treatment: 'domestic', net_amount_minor: 50, vat_amount_minor: 4, total_amount_minor: 54, event_id: null, cat_account_id: null }];
expenseRows = [{ id: 9, created_at: '2026-01-30', kind: 'amount', description: 'x', tax_treatment: 'domestic', event_id: null, chf_amount_minor: 200, net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null }];
const { postings } = await ledgerService.buildPostings(period);
expect(postings.map((p) => p.source)).toEqual(['incoming', 'expense', 'revenue']);
});
it('requires from/to/currency', async () => {
await expect(ledgerService.buildPostings({})).rejects.toThrow(/from.+to/);
await expect(ledgerService.buildPostings({ from: '2026-01-01', to: '2026-03-31' })).rejects.toThrow(/currency/);
});
});
// ----- exportPostings --------------------------------------------------
describe('exportPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
beforeEach(() => {
invoiceRows = [{ id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810, customer_company_name: 'ACME' }];
});
it('generic format carries all human-friendly columns', async () => {
const { content, filename, count } = await ledgerService.exportPostings({ ...period, format: 'generic' });
const [header, row] = content.trim().split('\r\n');
expect(count).toBe(1);
expect(header).toContain('DebitAccountName');
expect(header).toContain('NetAmount');
expect(header).toContain('VatCode');
expect(row).toContain('1100');
expect(row).toContain('108.10'); // gross 2dp
expect(filename).toMatch(/_generic\.csv$/);
});
it('banana format is a TAB-separated .txt with Banana column names', async () => {
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' });
const header = content.split('\r\n')[0];
// Banana's "Text file with column headers" import wants TAB-separated,
// unquoted values in a .txt — not a comma CSV.
expect(header).toBe('Date\tDoc\tDescription\tAccountDebit\tAccountCredit\tAmount\tVatCode');
expect(content.split('\r\n')[1]).toContain('\t');
expect(content).not.toContain('"');
expect(filename).toMatch(/_banana\.txt$/);
expect(contentType).toMatch(/text\/plain/);
});
it('banana_ie format is Income & Expense columns, tab-separated .txt', async () => {
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana_ie' });
const [header, row] = content.trim().split('\r\n');
expect(header).toBe('Date\tDoc\tDescription\tIncome\tExpenses\tCategory\tVatCode');
// The mock period holds one revenue posting (gross 108.10) → Income filled,
// Expenses empty, Category = the revenue account.
const cells = row.split('\t');
expect(cells[3]).toBe('108.10'); // Income
expect(cells[4]).toBe(''); // Expenses
expect(cells[5]).not.toBe(''); // Category (revenue account)
expect(filename).toMatch(/_banana_ie\.txt$/);
expect(contentType).toMatch(/text\/plain/);
});
it('formats a Postgres Date object as yyyy-mm-dd (not "Thu Jan ...")', async () => {
// PG returns DATE columns as JS Date objects (SQLite returns strings); the
// export must still emit an ISO date, or Banana rejects it and the Date
// column imports empty.
invoiceRows = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: new Date(2026, 0, 10),
vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
customer_company_name: 'ACME',
}];
const { content } = await ledgerService.exportPostings({ ...period, format: 'banana' });
const dateCell = content.split('\r\n')[1].split('\t')[0];
expect(dateCell).toBe('2026-01-10');
});
it('bexio format includes tax_code + currency', async () => {
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
const header = content.split('\r\n')[0];
expect(header).toContain('tax_code');
expect(header).toContain('currency');
});
it('unknown format falls back to generic', async () => {
const { filename } = await ledgerService.exportPostings({ ...period, format: 'nope' });
expect(filename).toMatch(/_generic\.csv$/);
});
});
@@ -0,0 +1,193 @@
/**
* Unit tests for mfaService — admin TOTP MFA (#738).
*
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
* derivation has key material (the service derives the AES key from
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
*/
// Must be set BEFORE the service is required — the key is derived lazily per
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
const { authenticator } = require('otplib');
const mfaService = require('../../src/services/mfaService');
describe('mfaService — secret encryption (AES-256-GCM)', () => {
it('round-trips encrypt → decrypt to the original secret', () => {
const secret = mfaService.generateSecret();
const blob = mfaService.encryptSecret(secret);
expect(blob).toEqual(expect.any(String));
expect(blob).not.toContain(secret); // stored form is not plaintext
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
expect(mfaService.decryptSecret(blob)).toBe(secret);
});
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
const secret = mfaService.generateSecret();
const a = mfaService.encryptSecret(secret);
const b = mfaService.encryptSecret(secret);
expect(a).not.toBe(b);
expect(mfaService.decryptSecret(a)).toBe(secret);
expect(mfaService.decryptSecret(b)).toBe(secret);
});
it('throws when decrypting a malformed blob (wrong segment count)', () => {
expect(() => mfaService.decryptSecret('garbage')).toThrow();
expect(() => mfaService.decryptSecret('only.two')).toThrow();
});
it('throws when the auth tag / ciphertext is tampered with', () => {
const secret = mfaService.generateSecret();
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
// Flip a character in the ciphertext → GCM auth check must fail.
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
});
});
describe('mfaService — TOTP verification', () => {
it('accepts a freshly generated code for the plaintext secret', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotp(code, secret)).toBe(true);
});
it('tolerates whitespace in the submitted code', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
});
it('rejects a wrong code', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
const wrong = code === '000000' ? '111111' : '000000';
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
});
it('returns false for empty inputs rather than throwing', () => {
const secret = mfaService.generateSecret();
expect(mfaService.verifyTotp('', secret)).toBe(false);
expect(mfaService.verifyTotp('123456', '')).toBe(false);
expect(mfaService.verifyTotp(null, secret)).toBe(false);
});
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
const secret = mfaService.generateSecret();
const stored = mfaService.encryptSecret(secret);
const code = authenticator.generate(secret);
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
const wrong = code === '000000' ? '111111' : '000000';
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
});
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
});
});
describe('mfaService — otpauth URI / QR', () => {
it('builds an otpauth:// URI containing issuer, account and secret', () => {
const secret = mfaService.generateSecret();
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
expect(uri).toMatch(/^otpauth:\/\/totp\//);
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
expect(uri).toContain(`secret=${secret}`);
});
it('builds a PNG data-URL QR for the URI', async () => {
const secret = mfaService.generateSecret();
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
const qr = await mfaService.buildQrDataUrl(uri);
expect(qr).toMatch(/^data:image\/png;base64,/);
});
});
describe('mfaService — recovery codes', () => {
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
expect(new Set(plain).size).toBe(10);
expect(new Set(hashed).size).toBe(10);
// Hashes are bcrypt, not the plaintext.
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
plain.forEach((p) => expect(hashed).not.toContain(p));
});
it('formats a raw code into 4-char groups', () => {
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
});
it('consumes a valid recovery code once and removes it (single-use)', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
const target = plain[3];
const first = await mfaService.consumeRecoveryCode(target, hashed);
expect(first.matched).toBe(true);
expect(first.remainingHashes).toHaveLength(9);
// Reusing the same code against the reduced set must now fail.
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
expect(reuse.matched).toBe(false);
expect(reuse.remainingHashes).toHaveLength(9);
});
it('matches case-insensitively and trims whitespace', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
expect(res.matched).toBe(true);
});
it('rejects a wrong code and leaves the hash set unchanged', async () => {
const { hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
expect(res.matched).toBe(false);
expect(res.remainingHashes).toHaveLength(10);
});
it('handles empty / missing input safely', async () => {
const { hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode('', hashed);
expect(res.matched).toBe(false);
expect(res.remainingHashes).toBe(hashed);
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
expect(noHashes.matched).toBe(false);
expect(noHashes.remainingHashes).toEqual([]);
});
});
describe('mfaService — parseRecoveryCodes', () => {
it('parses a JSON string array', () => {
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
});
it('passes an already-array through', () => {
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
});
it('returns [] for null / garbage / non-array JSON', () => {
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
});
});
describe('mfaService — isEnrolled coercion', () => {
it('treats true / 1 / "1" as enrolled', () => {
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
});
it('treats false / 0 / null / missing as not enrolled', () => {
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
expect(mfaService.isEnrolled({})).toBe(false);
expect(mfaService.isEnrolled(null)).toBe(false);
});
});
@@ -0,0 +1,236 @@
/**
* Tests for the createBaseDocument + getPageMetrics helpers — the
* shared PDF factory used by quote/invoice rendering AND by the
* upcoming tax-report renderer. These verify orientation handling
* and font defaults without touching DB or filesystem.
*/
const pdfService = require('../../src/services/pdfService');
describe('getPageMetrics', () => {
it('returns portrait A4 metrics by default', () => {
const p = pdfService.getPageMetrics();
expect(p.width).toBeCloseTo(595.28, 1);
expect(p.height).toBeCloseTo(841.89, 1);
expect(p.contentWidth).toBeCloseTo(515.28, 1);
});
it('returns portrait when orientation is "portrait"', () => {
const p = pdfService.getPageMetrics('portrait');
expect(p.width).toBeLessThan(p.height);
});
it('returns landscape A4 metrics (width > height) when orientation is "landscape"', () => {
const p = pdfService.getPageMetrics('landscape');
expect(p.width).toBeCloseTo(841.89, 1);
expect(p.height).toBeCloseTo(595.28, 1);
expect(p.contentWidth).toBeCloseTo(761.89, 1);
expect(p.width).toBeGreaterThan(p.height);
});
it('ignores unknown orientation values (falls back to portrait)', () => {
const p = pdfService.getPageMetrics('upside-down');
expect(p.width).toBeLessThan(p.height);
});
});
describe('createBaseDocument', () => {
it('returns a PDFKit doc, page metrics, and logical font names by default', () => {
const { doc, page, fonts } = pdfService.createBaseDocument();
expect(doc).toBeDefined();
expect(typeof doc.on).toBe('function');
expect(typeof doc.font).toBe('function');
expect(page.width).toBeCloseTo(595.28, 1); // portrait by default
expect(fonts).toEqual({ body: 'Helvetica', bold: 'Helvetica-Bold' });
});
it('produces a landscape document when orientation is "landscape"', () => {
const { doc, page } = pdfService.createBaseDocument({ orientation: 'landscape' });
expect(page.width).toBeGreaterThan(page.height);
// PDFKit stores the active page dims on doc.page.
expect(doc.page.width).toBeCloseTo(841.89, 1);
expect(doc.page.height).toBeCloseTo(595.28, 1);
});
it('produces a buffered PDF of non-zero size with the PDF magic header', async () => {
const { doc } = pdfService.createBaseDocument({ orientation: 'landscape' });
const chunks = [];
doc.on('data', (c) => chunks.push(c));
const ended = new Promise((resolve) => doc.on('end', resolve));
doc.text('hello', 40, 40);
doc.end();
await ended;
const buf = Buffer.concat(chunks);
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('keeps Helvetica fonts when the issuer has no custom TTF path', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: null },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('falls back to Helvetica when the custom TTF path does not exist', () => {
// No exception, no logger.error blow-up — just silent fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontTtfPath: '/nonexistent/path/font.ttf' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('registers a bundled font family when pdfFontFamily is set', () => {
// Migration-121 dropdown path. Inter ships 400 + 600 + 700 under
// backend/assets/fonts/Inter/, so the resolver should pick 400
// for body and 700 for bold.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'Inter' },
});
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('falls back to Helvetica when pdfFontFamily names a non-existent directory', () => {
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: 'NotARealFamily' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('strips path-traversal characters from pdfFontFamily', () => {
// Defence in depth: the sanitiser keeps only [A-Za-z0-9_-].
// "../../etc/passwd" becomes "etcpasswd" → no such font dir → fallback.
const { fonts } = pdfService.createBaseDocument({
issuer: { pdfFontFamily: '../../etc/passwd' },
});
expect(fonts.body).toBe('Helvetica');
expect(fonts.bold).toBe('Helvetica-Bold');
});
it('prefers pdfFontTtfPath over pdfFontFamily when both are set', () => {
// The explicit upload is the priority-1 override. When the upload
// path is unusable (file missing) the family is consulted next.
// Here we set BOTH to invalid values and confirm Helvetica fallback
// — what matters is that the family DIDN'T get registered while a
// (failed) explicit path was being evaluated.
const { fonts } = pdfService.createBaseDocument({
issuer: {
pdfFontTtfPath: '/nonexistent/path/font.ttf',
pdfFontFamily: 'Inter',
},
});
// pdfFontTtfPath misses → falls through to pdfFontFamily → Inter
// registers successfully. crm-body / crm-bold confirm a custom
// font won.
expect(fonts.body).toBe('crm-body');
expect(fonts.bold).toBe('crm-bold');
});
it('forwards PDF info metadata (Title, Author) to the document', () => {
const { doc } = pdfService.createBaseDocument({
info: { Title: 'Tax Report 2026', Author: 'picpeak' },
});
// PDFKit copies these onto doc.info during construction.
expect(doc.info.Title).toBe('Tax Report 2026');
expect(doc.info.Author).toBe('picpeak');
});
});
describe('exported letterhead helper', () => {
it('exposes drawIssuerBlock for reuse by non-quote/invoice renderers', () => {
expect(typeof pdfService.drawIssuerBlock).toBe('function');
});
});
// Storno rendering — smoke-tests that exercise the kind='storno'
// branch in renderInvoiceToBuffer. We can't search the PDF buffer
// directly for German strings because PDFKit Flate-compresses
// content streams, but we CAN verify the renderer:
// - completes without throwing on a Storno-shaped context,
// - produces a valid %PDF magic header,
// - produces a SMALLER document than its invoice counterpart
// (no payment block, no QR slip → fewer bytes), proving the
// suppression branches actually fire.
//
// Visual correctness (title swap, reference line, signed totals) is
// validated by manual review of a real Storno PDF; the renderer's
// branch logic is unit-tested in service tests where the inputs
// can be asserted directly.
describe('renderInvoiceToBuffer — Storno branch', () => {
function buildContext(overrides = {}) {
return {
locale: 'de',
currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 7.7, vatAmountMinor: 2310,
shippingAmountMinor: 0, totalAmountMinor: 32310,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
// Bank + payment term are part of the baseline invoice so the
// payment block renders a real IBAN + Zahlungsbedingungen
// section. The Storno branch suppresses this entirely, which
// produces a visible byte-size delta.
bank: {
accountHolder: 'AcmeCo',
iban: 'CH9300762011623852957',
bic: 'POFICHBE',
currency: 'CHF',
},
qrFormat: 'none',
paymentTerm: { netDays: 30, skontoPercent: 2, skontoWithinDays: 10 },
...overrides,
};
}
it('renders a valid Storno PDF (kind="storno", negated totals)', async () => {
const buf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(buf.length).toBeGreaterThan(0);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('produces a smaller PDF than the equivalent invoice (no payment block, no QR slip)', async () => {
// Baseline: normal invoice with a payment block.
const invoiceBuf = await pdfService.renderInvoiceToBuffer(buildContext());
// Storno: same context but kind='storno' → payment block + QR
// both suppressed. Payment block alone is ~80pt tall in the
// PDF; its absence is reliably detectable as a byte-size delta.
const stornoBuf = await pdfService.renderInvoiceToBuffer(buildContext({
totals: {
netAmountMinor: -30000, vatRate: 7.7, vatAmountMinor: -2310,
shippingAmountMinor: 0, totalAmountMinor: -32310,
},
doc: {
kind: 'storno',
invoiceNumber: 'R-2026-0080',
issueDate: '2026-05-15',
cancelsInvoice: { number: 'R-2026-0042', issueDate: '2026-04-12' },
},
}));
expect(stornoBuf.length).toBeGreaterThan(0);
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
@@ -0,0 +1,95 @@
/**
* Pure-function tests for the PDF rendering helpers. These are the
* functions that DON'T touch PDFKit / DB — formatting, salutation
* routing, EPC payload construction.
*
* The helpers aren't directly exported from pdfService.js (it
* exports renderQuoteToBuffer / renderInvoiceToBuffer); we reach
* them via `_internal` which the module already exposes for tests.
*/
const pdfService = require('../../src/services/pdfService');
const { formatMinor, formatDate, t } = pdfService._internal;
describe('formatMinor', () => {
it('formats CHF cents with 2 decimals (123456 minor = 1234.56 major)', () => {
// de-CH uses (U+2019) as the thousands separator.
expect(formatMinor(123456, 'CHF', 'de-CH')).toMatch(/1[',\u2019]?234\.56/);
});
it('formats large amounts with thousands separators', () => {
// 12345600 minor units = 123,456.00 major; the separator
// varies by locale (de-CH = U+2019, en-GB = ',').
expect(formatMinor(12345600, 'CHF', 'de-CH')).toMatch(/123[',\u2019]456\.00/);
});
it('returns 0,00 for zero or null', () => {
expect(formatMinor(0, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
expect(formatMinor(null, 'CHF', 'de-CH')).toMatch(/0[,.]00/);
});
it('returns 2-decimal output regardless of locale', () => {
expect(formatMinor(99, 'EUR', 'en-GB')).toMatch(/0[.,]99/);
});
});
describe('formatDate', () => {
// formatDate now respects ctx.dateFormat (object with `format`
// key) — when omitted defaults to DD.MM.YYYY.
it('defaults to DD.MM.YYYY when no format passed', () => {
expect(formatDate('2026-04-19')).toBe('19.04.2026');
});
it('honors the configured DD/MM/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'DD/MM/YYYY' })).toBe('19/04/2026');
});
it('honors the configured MM/DD/YYYY format', () => {
expect(formatDate('2026-04-19', { format: 'MM/DD/YYYY' })).toBe('04/19/2026');
});
it('honors ISO YYYY-MM-DD', () => {
expect(formatDate('2026-04-19', { format: 'YYYY-MM-DD' })).toBe('2026-04-19');
});
it('returns empty string on empty input', () => {
expect(formatDate('')).toBe('');
expect(formatDate(null)).toBe('');
expect(formatDate(undefined)).toBe('');
});
it('returns empty string on invalid input rather than throwing', () => {
expect(formatDate('not-a-date')).toBe('');
});
it('accepts Date objects', () => {
expect(formatDate(new Date('2026-04-19T12:00:00Z'))).toMatch(/^(19|20)\.0[34]\.2026$/);
});
});
describe('t (i18n lookup)', () => {
it('returns the EN value for an EN-only locale', () => {
expect(t('en', 'invoice_title')).toBe('Invoice');
expect(t('en', 'quote_title')).toBe('Quote');
});
it('returns the DE value for de locale', () => {
expect(t('de', 'invoice_title')).toBe('Rechnung');
expect(t('de', 'quote_title')).toBe('Angebot');
});
it('falls back to EN for unknown locales', () => {
expect(t('xx', 'invoice_title')).toBe('Invoice');
});
it('substitutes named tokens like {percent}', () => {
const out = t('en', 'skonto_phrase', { percent: 3, days: 5 });
expect(out).toMatch(/3% discount if paid within 5 working days\./);
});
it('falls back to EN when the key is missing on the requested locale', () => {
// page_of is seeded on all locales — pick something that
// exists on EN with a substitution.
const out = t('zz', 'page_of', { current: 1, total: 3 });
expect(out).toBe('Page 1 of 3');
});
});
@@ -0,0 +1,71 @@
/**
* exportAsTxt — issue #623 regression test.
*
* The admin UI labels the TXT export "for Lightroom search". Lightroom's
* filename search wants ONE comma-separated line WITHOUT file extensions
* (the gallery JPEGs may map to RAW files in the catalog). The frontend
* now passes separator='comma' + include_extension=false for the TXT
* format; this test pins the resulting shape so a future refactor can't
* silently regress it back to the newline-separated form the bug reported.
*
* Also pins backward compatibility: a direct API caller passing no options
* still gets the original newline-with-extension behaviour, so existing
* integrations don't break.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/services/xmpGenerator', () => ({ XmpGenerator: class {} }));
const { PhotoExportService } = require('../../src/services/photoExportService');
const service = new PhotoExportService();
const PHOTOS = [
{ original_filename: 'IMG_0001.jpg', filename: 'abc123.jpg' },
{ original_filename: 'IMG_0002.JPEG', filename: 'def456.jpeg' },
{ original_filename: 'shoot.final.tif', filename: 'ghi789.tif' },
{ original_filename: null, filename: 'fallback.png' }, // null original → falls back to filename
];
describe('exportAsTxt (issue #623)', () => {
it('Lightroom mode: comma-joined, no extension, no space', () => {
const result = service.exportAsTxt(PHOTOS, {
separator: 'comma',
include_extension: false,
});
expect(result.content).toBe('IMG_0001,IMG_0002,shoot.final,fallback');
expect(result.contentType).toBe('text/plain');
});
it('backward compatible: no options → newline-joined with extensions', () => {
const result = service.exportAsTxt(PHOTOS);
expect(result.content).toBe(
'IMG_0001.jpg\nIMG_0002.JPEG\nshoot.final.tif\nfallback.png',
);
});
it('semicolon separator joins without a trailing space', () => {
const result = service.exportAsTxt(PHOTOS, {
separator: 'semicolon',
include_extension: false,
});
expect(result.content).toBe('IMG_0001;IMG_0002;shoot.final;fallback');
});
it('filename_format=picpeak uses photo.filename (hashed) instead of original', () => {
const result = service.exportAsTxt(PHOTOS, {
filename_format: 'picpeak',
separator: 'comma',
include_extension: false,
});
expect(result.content).toBe('abc123,def456,ghi789,fallback');
});
it('extension stripping uses only the last segment ("a.b.c" → "a.b")', () => {
// path.parse('shoot.final.tif').name === 'shoot.final' — Lightroom
// catalogs that store basenames like "shoot.final" still match.
const result = service.exportAsTxt(
[{ original_filename: 'shoot.final.tif', filename: 'x.tif' }],
{ separator: 'comma', include_extension: false },
);
expect(result.content).toBe('shoot.final');
});
});
@@ -0,0 +1,111 @@
/**
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
* as in production. Reconciliation is non-destructive (update-in-place / rename,
* never delete) so restored rows referenced by FKs keep their ids.
*/
const knex = require('knex');
let db;
let reinjectCurrentAdmin;
beforeAll(() => {
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
});
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id');
t.integer('created_by');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
});
afterEach(async () => { await db.destroy(); });
const operator = {
id: 1, username: 'admin', email: 'op@example.com',
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
};
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
await db('admin_users').insert({
id: 7, username: 'someoneelse', email: 'OP@example.com',
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users');
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.id).toBe(7); // id preserved → FK refs hold
expect(row.username).toBe('admin');
expect(row.password_hash).toBe('OP_HASH');
expect(Boolean(row.two_factor_enabled)).toBe(true);
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
// dangling role_id/created_by on a cross-instance restore) — the restored
// row keeps its own already-valid values.
expect(row.role_id).toBe(4);
expect(row.created_by).toBe(5);
});
test('renames (not deletes) a different row holding the operator username', async () => {
await db('admin_users').insert({
id: 3, username: 'admin', email: 'other@instance.test',
password_hash: 'OTHER', is_active: 1, role_id: 4,
});
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
const other = rows.find((r) => r.id === 3);
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
expect(other.email).toBe('other@instance.test');
const op = rows.find((r) => r.username === 'admin');
expect(op.password_hash).toBe('OP_HASH');
});
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
await db('admin_users').insert([
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
]);
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // both rows survive
const opRow = rows.find((r) => r.id === 4); // email match updated in place
expect(opRow.username).toBe('admin');
expect(opRow.password_hash).toBe('OP_HASH');
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
expect(renamed.username).toBe('admin__restored_5');
});
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
await db('admin_users').insert({
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // backup admin untouched
const opRow = rows.find((r) => r.username === 'admin');
expect(opRow.password_hash).toBe('OP_HASH');
expect(opRow.id).toBe(10); // max(9)+1, no collision
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
@@ -0,0 +1,217 @@
/**
* Tests for the migration-119 hierarchy support in quoteService:
* computeTotals, validateLineItemHierarchy, and the two-phase
* insertLineItemsHierarchical helper. All pure / db-mocked so the
* suite runs fast and is deterministic.
*/
const quoteService = require('../../src/services/quoteService');
const {
computeTotals,
validateLineItemHierarchy,
insertLineItemsHierarchical,
} = quoteService._internal;
describe('computeTotals — hierarchy + parent auto-resolve rule', () => {
it('parent total auto-resolves to sum of priced sub-items (parent unit_price ignored)', () => {
const items = [
// Parent with its own price €500 — should be IGNORED because
// sub-items have prices. Parent's effective line_total becomes
// sum of priced sub-items.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
// Priced sub-items €150 + €200 = €350
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
{ position: 3, quantity: 1, unit_price_minor: 20000, discount_percent: 0, parent_position: 1 },
// Another top-level item: €100
{ position: 4, quantity: 2, unit_price_minor: 5000, discount_percent: 0 },
];
const out = computeTotals(items, 0, 0);
// Net = 35000 (parent 1, auto-resolved) + 10000 (row 4) = 45000.
// Parent's own €500 is silently overridden.
expect(out.netAmountMinor).toBe(45000);
// Parent's stored line_total_minor reflects the resolved sum.
expect(out.lineItems[0].line_total_minor).toBe(35000);
});
it('priceless sub-items leave the parent\'s own line_total intact', () => {
const items = [
// Parent €500 with three priceless transparency-bullets — the
// €500 stands.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
];
const out = computeTotals(items, 0, 0);
expect(out.netAmountMinor).toBe(50000);
expect(out.lineItems[0].line_total_minor).toBe(50000);
});
it('mixed priced + priceless sub-items: only priced contribute, parent\'s own price still overridden', () => {
const items = [
// Parent €500 → overridden because at least one sub-item is priced.
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 15000, discount_percent: 0, parent_position: 1 },
// Priceless bullet — doesn't add anything
{ position: 3, quantity: 1, unit_price_minor: 0, discount_percent: 0, parent_position: 1 },
];
const out = computeTotals(items, 0, 0);
// Parent resolves to €150 (only priced sub-item).
expect(out.netAmountMinor).toBe(15000);
expect(out.lineItems[0].line_total_minor).toBe(15000);
});
it('still computes line_total_minor on sub-items so the renderer can show it', () => {
const out = computeTotals([
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0 },
{ position: 2, quantity: 2, unit_price_minor: 15000, discount_percent: 10, parent_position: 1 },
], 0);
expect(out.lineItems[1].line_total_minor).toBe(27000); // 2 × 150.00 × 0.9 = 270.00
});
it('applies VAT to the resolved parent total', () => {
const out = computeTotals([
// Parent €1000 overridden by priced €800 sub-item
{ position: 1, quantity: 1, unit_price_minor: 100000, discount_percent: 0 },
{ position: 2, quantity: 1, unit_price_minor: 80000, discount_percent: 0, parent_position: 1 },
], 7.7);
// Resolved net = 80000, VAT 7.7% = 6160.
expect(out.netAmountMinor).toBe(80000);
expect(out.vatAmountMinor).toBe(6160);
expect(out.totalAmountMinor).toBe(86160);
});
it('treats empty-string parent_position as top-level (frontend may send "")', () => {
const out = computeTotals([
{ position: 1, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: '' },
{ position: 2, quantity: 1, unit_price_minor: 50000, discount_percent: 0, parent_position: null },
], 0);
expect(out.netAmountMinor).toBe(100000);
});
});
describe('validateLineItemHierarchy', () => {
it('accepts a flat list of top-level items', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2 },
{ position: 3 },
])).not.toThrow();
});
it('accepts one level of sub-items under valid parents', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2, parent_position: 1 },
{ position: 3, parent_position: 1 },
{ position: 4 },
{ position: 5, parent_position: 4 },
])).not.toThrow();
});
it('rejects duplicate positions', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 1 },
])).toThrow(/Duplicate line item position/);
});
it('rejects a sub-item pointing at a missing parent', () => {
expect(() => validateLineItemHierarchy([
{ position: 1, parent_position: 99 },
])).toThrow(/missing parent position/);
});
it('rejects a sub-item under another sub-item (max 1 level deep)', () => {
expect(() => validateLineItemHierarchy([
{ position: 1 },
{ position: 2, parent_position: 1 },
{ position: 3, parent_position: 2 },
])).toThrow(/max one level deep/);
});
it('rejects an item whose parent is itself', () => {
expect(() => validateLineItemHierarchy([
{ position: 5, parent_position: 5 },
])).toThrow(/cannot be its own parent/);
});
it('rejects an item without a positive position', () => {
expect(() => validateLineItemHierarchy([
{ position: 0 },
])).toThrow(/positive position/);
});
it('is a no-op on empty / non-array input', () => {
expect(() => validateLineItemHierarchy([])).not.toThrow();
expect(() => validateLineItemHierarchy(null)).not.toThrow();
expect(() => validateLineItemHierarchy(undefined)).not.toThrow();
});
});
describe('insertLineItemsHierarchical', () => {
// Tiny trx mock — captures insert calls so we can verify the
// two-phase ordering and the parent-id remap. `.returning('id')`
// returns a synthesised id matching the call order.
function makeTrxMock() {
let nextId = 100;
const inserts = []; // [{ table, row }]
const trx = (tableName) => ({
insert(row) {
const id = nextId++;
inserts.push({ table: tableName, row: { ...row, id } });
return {
returning() { return Promise.resolve([{ id }]); },
then(resolve) { return Promise.resolve(undefined).then(resolve); }, // bare await: no returning() call
};
},
});
return { trx, inserts };
}
it('inserts top-level items first, then sub-items with remapped parent_line_item_id', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
{ position: 1, description: 'Package', quantity: 1, unit_price_minor: 50000, discount_percent: 0, line_total_minor: 50000, parent_position: null },
{ position: 2, description: 'Camera', quantity: 1, unit_price_minor: 15000, discount_percent: 0, line_total_minor: 15000, parent_position: 1 },
{ position: 3, description: 'Lens', quantity: 1, unit_price_minor: 20000, discount_percent: 0, line_total_minor: 20000, parent_position: 1 },
{ position: 4, description: 'Travel', quantity: 1, unit_price_minor: 10000, discount_percent: 0, line_total_minor: 10000, parent_position: null },
]);
// 4 inserts, all into quote_line_items.
expect(inserts).toHaveLength(4);
expect(inserts.every((i) => i.table === 'quote_line_items')).toBe(true);
// Order: top-level first (positions 1 and 4), then sub-items 2 and 3.
expect(inserts.map((i) => i.row.position)).toEqual([1, 4, 2, 3]);
// Top-level items have parent_line_item_id = null.
expect(inserts[0].row.parent_line_item_id).toBeNull();
expect(inserts[1].row.parent_line_item_id).toBeNull();
// Sub-items reference the id returned for position-1 parent (100).
expect(inserts[2].row.parent_line_item_id).toBe(100);
expect(inserts[3].row.parent_line_item_id).toBe(100);
// parent_position is stripped (wire-only field, not a DB column).
expect(inserts[0].row).not.toHaveProperty('parent_position');
expect(inserts[2].row).not.toHaveProperty('parent_position');
});
it('copies details_text through to the inserted row', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, [
{ position: 1, description: 'P', unit_price_minor: 0, details_text: 'Includes online gallery.', parent_position: null },
]);
expect(inserts[0].row.details_text).toBe('Includes online gallery.');
});
it('is a no-op on empty items array', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', 1, []);
expect(inserts).toHaveLength(0);
});
it('uses the supplied ownerColumn so the same helper handles invoice_line_items', async () => {
const { trx, inserts } = makeTrxMock();
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', 42, [
{ position: 1, description: 'X', unit_price_minor: 100, parent_position: null },
]);
expect(inserts[0].table).toBe('invoice_line_items');
expect(inserts[0].row.invoice_id).toBe(42);
expect(inserts[0].row).not.toHaveProperty('quote_id');
});
});
@@ -0,0 +1,179 @@
/**
* Tests for quoteService lock + state-transition guards:
* - updateQuote refuses on accepted / declined / converted
* - adminAcceptQuote refuses on already-terminal states + atomic
* update path
*
* db deep-mocked, same chain pattern as invoiceService tests.
*/
const tableChains = {};
function makeChain() {
return {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
// knex chains are thenable; mirror that so `await trx('t')...`
// resolves to an array of rows.
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._selectResult); }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
leftJoin: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
};
}
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/services/pdfService', () => ({
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const quoteService = require('../../src/services/quoteService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
}
describe('quoteService.updateQuote — lock guards', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.updateQuote(99, {}, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('locks accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('locks converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.updateQuote(1, {}, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_LOCKED' });
});
it('allows edits on draft + sent + expired (no QUOTE_LOCKED throw)', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, vat_rate: 0, shipping_amount_minor: 0,
};
// The lock check sits at the TOP of updateQuote. The
// observable behavior we care about is "no QUOTE_LOCKED
// 409 thrown on these statuses". The full transaction
// path may resolve to anything (incl. undefined) since
// the test mocks the trx callback — that's fine.
let err = null;
try { await quoteService.updateQuote(1, { lineItems: [] }, 1); }
catch (e) { err = e; }
if (err) {
// Any error other than the QUOTE_LOCKED guard is allowed
// (we're not exercising the full path here).
expect(err.code).not.toBe('QUOTE_LOCKED');
}
resetChains();
}
});
});
describe('quoteService.adminAcceptQuote', () => {
beforeEach(() => resetChains());
it('404s when the quote does not exist', async () => {
pickChainFor('quotes')._firstValue = null;
await expect(quoteService.adminAcceptQuote(99, 1))
.rejects.toMatchObject({ statusCode: 404 });
});
it('refuses already-accepted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'accepted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_ALREADY_ACCEPTED' });
});
it('refuses declined quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'declined' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_DECLINED' });
});
it('refuses converted quotes', async () => {
pickChainFor('quotes')._firstValue = { id: 1, status: 'converted' };
await expect(quoteService.adminAcceptQuote(1, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'QUOTE_CONVERTED' });
});
it('accepts draft / sent / expired and returns lockedAt', async () => {
for (const status of ['draft', 'sent', 'expired']) {
pickChainFor('quotes')._firstValue = {
id: 1, status, customer_account_id: 5,
currency: 'CHF', language: 'de',
quote_number: 'Q-2026-0001',
total_amount_minor: 10000,
event_name: null,
};
pickChainFor('customer_accounts')._firstValue = {
id: 5, email: 'c@example.com', display_name: 'Test',
};
pickChainFor('quote_line_items')._selectResult = [];
pickChainFor('business_profile')._firstValue = null;
const result = await quoteService.adminAcceptQuote(1, 42);
expect(result.status).toBe('accepted');
expect(result.lockedAt).toBeInstanceOf(Date);
resetChains();
}
});
});
@@ -0,0 +1,10 @@
const rasterizeService = require('../../src/services/rasterizeService');
// The page-range guard runs BEFORE any fs/pdftoppm work, so these reject
// without touching the binary or disk (PR #622 concern 6).
describe('getRenderedPagePath page-range guard', () => {
it.each([0, -1, 201, 1000, 1.5, NaN])('rejects out-of-range page %p', async (page) => {
await expect(rasterizeService.getRenderedPagePath(1, '/tmp/does-not-exist.pdf', page))
.rejects.toMatchObject({ statusCode: 400, code: 'PAGE_OUT_OF_RANGE' });
});
});
@@ -0,0 +1,115 @@
/**
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
* `umamiAdapter` test contract: missing config / URL shape / encoding /
* normalisation / unknown-bucket drop / failure modes.
*
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
* envelopes since their docs hint at minor v0 → v1 shape variation.
*/
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete', async () => {
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends Bearer auth', async () => {
mockJson([{ device: 'desktop', sessions: 10 }]);
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
expect(init.headers.Authorization).toBe('Bearer rkey');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ device: 'desktop', sessions: 1 }]);
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
});
test('normalises a typical {device, sessions} payload into percentages', async () => {
mockJson([
{ device: 'desktop', sessions: 60 },
{ device: 'mobile', sessions: 30 },
{ device: 'tablet', sessions: 10 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('accepts the {data: [...]} envelope variant', async () => {
mockJson({ data: [
{ device: 'desktop', sessions: 1 },
{ device: 'mobile', sessions: 3 },
] });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
});
test('falls back to `visitors` when `sessions` is absent', async () => {
mockJson([
{ device: 'desktop', visitors: 80 },
{ device: 'mobile', visitors: 20 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('tolerates a `dimension` key as the bucket label', async () => {
mockJson([
{ dimension: 'desktop', sessions: 50 },
{ dimension: 'mobile', sessions: 50 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
});
test('drops unknown buckets', async () => {
mockJson([
{ device: 'desktop', sessions: 80 },
{ device: 'mobile', sessions: 20 },
{ device: 'fridge', sessions: 100 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => ({
ok: true, status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -0,0 +1,263 @@
/**
* Smoke tests for taxReportService.renderTaxReportPdf and
* renderTaxReportCsv. We deep-mock the db (canned invoice rows) +
* businessProfileService (canned issuer) and assert that the
* rendered output meets a few hard requirements:
*
* - PDF starts with the %PDF magic bytes, is non-empty
* - CSV header contains the localised column names
* - CSV body contains the invoice numbers in order
* - CSV totals row contains the grand totals
*/
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let callCount = 0;
function makeChain(initialRows) {
return {
_rows: initialRows,
then(onResolve, onReject) {
return Promise.resolve(this._rows).then(onResolve, onReject);
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
orderByRaw: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
}
const mockDbFn = jest.fn((tableName) => {
// Route by table name when supplied — the Skonto aggregate (added
// by migration 126) queries `invoice_payment_log`; the #4 cost side
// queries `inbound_documents` + `expenses`; everything else (main
// listing, replacements lookup) hits `invoices`.
if (tableName === 'invoice_payment_log') return makeChain([]);
if (tableName === 'inbound_documents') return makeChain([]);
if (tableName === 'expenses') return makeChain([]);
callCount += 1;
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments so the raw() return value just needs to exist.
mockDbFn.raw = jest.fn((sql) => sql);
// #4 loadCosts schema-guards each cost table; default the PDF/CSV
// fixtures to "no accounting tables" so these renderers exercise the
// revenue path unchanged.
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: {
company_name: 'ACME Test GmbH',
address_line1: 'Teststrasse 1',
postal_code: '8000',
city: 'Zürich',
country_code: 'CH',
email: 'hello@example.com',
default_locale: 'de',
default_currency: 'CHF',
pdf_show_logo: 1,
pdf_show_company_name: 1,
pdf_logo_height: 56,
pdf_company_name_inline: 0,
pdf_folding_marks: 'none',
logo_path: null,
pdf_font_ttf_path: null,
},
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => ({ format: 'DD.MM.YYYY' })),
}));
const taxReportService = require('../../src/services/taxReportService');
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
callCount = 0;
mockDbFn.mockClear();
});
const SAMPLE_ROW = (override = {}) => ({
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'Test Kunde GmbH', customer_first_name: null,
customer_last_name: null, customer_display_name: null, customer_email: null,
event_name: 'Hochzeit Müller',
...override,
});
describe('renderTaxReportPdf', () => {
it('produces a non-empty PDF buffer with the %PDF magic header', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders a header even when no invoices are in the period', async () => {
invoiceRowsForRun = [];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders successfully when cancelled rows are present', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001', status: 'cancelled' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002', replaces_invoice_id: 1 }),
];
replacementsRowsForRun = [{ replaces_invoice_id: 1, invoice_number: 'R-2026-0002' }];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders without throwing when a row has long text that must wrap', async () => {
// Customer + event labels long enough to force multi-line wrap
// in their narrow columns. The dynamic row-height logic should
// grow the row to fit rather than overlapping the next one.
invoiceRowsForRun = [
SAMPLE_ROW({
customer_company_name: 'Sehr lange Firmenbezeichnung mit Adresszusatz GmbH & Co. KG',
event_name: 'Hochzeit Müller & Schmidt — ganztägige Reportage inkl. Empfang und Trauung',
}),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('honours the locale parameter (en) without throwing', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
// Regression: the page-number footer used to place its baseline
// inside the bottom margin, which made PDFKit auto-paginate one
// empty page per existing page (so a 1-page report ended up as 2,
// and so on). Counting `/Type /Page` markers in the raw PDF bytes
// is the cheapest way to detect a recurrence without parsing the
// PDF — every page object in the xref table carries that marker
// exactly once.
it('does not duplicate pages when stamping the page-number footer', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
const pageMarkers = buf.toString('binary').match(/\/Type\s*\/Page\b(?!s)/g) || [];
// Small single-row report should fit on a single page. The
// previous buggy renderer produced 2 (1 content + 1 footer-only).
expect(pageMarkers.length).toBe(1);
});
});
describe('renderTaxReportCsv', () => {
it('returns a CSV blob with the de localised header row', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(contentType).toMatch(/text\/csv/);
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
const lines = content.split('\r\n');
// Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the
// old Rechnung/Kunde split.
expect(lines[0]).toContain('Typ'); // de header for tax_col_type
expect(lines[0]).toContain('Referenz'); // de header for tax_col_reference
expect(lines[0]).toContain('Kunde'); // "Kunde / Lieferant"
expect(lines[0]).toContain('Netto');
});
it('lists each invoice on its own row in order', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
SAMPLE_ROW({ id: 3, invoice_number: 'R-2026-0003' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
const idxA = content.indexOf('R-2026-0001');
const idxB = content.indexOf('R-2026-0002');
const idxC = content.indexOf('R-2026-0003');
expect(idxA).toBeGreaterThan(0);
expect(idxB).toBeGreaterThan(idxA);
expect(idxC).toBeGreaterThan(idxB);
});
it('appends a trailing totals row with the grand totals', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
}),
SAMPLE_ROW({
id: 2, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
}),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Grand totals: net = 150.00, vat = 11.55, total = 161.55.
expect(content).toMatch(/"150\.00"/);
expect(content).toMatch(/"11\.55"/);
expect(content).toMatch(/"161\.55"/);
});
it('flags a cancelled row with a "(Cancelled)" suffix on its Reference cell', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ status: 'cancelled' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Unified ledger CSV has no separate cancelled column — a cancelled row is
// flagged by appending the localised "(Cancelled)" tag to its Reference.
const dataRow = content.split('\r\n')[1];
expect(dataRow).toContain('R-2026-0001 (Cancelled)');
});
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(content).toContain('\r\n');
// The route wraps the BOM around the content; the service output
// itself is BOM-free so callers (tests) get a clean string.
expect(content.charCodeAt(0)).not.toBe(0xFEFF);
});
});
@@ -0,0 +1,572 @@
/**
* Tests for taxReportService.
*
* Two layers:
* 1. Pure helpers (grossUpLateFee, computeReportedAmounts,
* buildCustomerLabel) — no db mock needed.
* 2. getTaxReport — db chain deep-mocked so we can feed canned
* invoice rows and assert the filter/bucket/total math.
*/
// ----- mock db chain ---------------------------------------------------
//
// taxReportService builds a single chain:
// db('invoices').leftJoin(...).leftJoin(...).whereBetween(...)
// .where(...).whereIn(...).orderBy(...).select(...)
// and then for cancelled ids:
// db('invoices').whereIn('replaces_invoice_id', ids).select(...)
//
// We use one shared chain factory that returns canned rows from
// `_selectResult` for the main query, and lets us swap the result
// for the replacements lookup via a "second-call" hook.
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let inboundRowsForRun = [];
let expenseRowsForRun = [];
let costTablesPresent = false;
let callCount = 0;
function makeChain(initialRows) {
const c = {
_rows: initialRows,
then: function (onResolve, onReject) {
return Promise.resolve(this._rows).then(onResolve, onReject);
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
orderByRaw: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
return c;
}
const mockDbFn = jest.fn((tableName) => {
// Migration 126 added a Skonto aggregate that hits
// `invoice_payment_log` — route those explicitly to an empty list so
// the test surface stays focused on the invoices/replacements flow.
if (tableName === 'invoice_payment_log') return makeChain([]);
// Cost side (#4): incoming invoices + internal expenses.
if (tableName === 'inbound_documents') return makeChain(inboundRowsForRun);
if (tableName === 'expenses') return makeChain(expenseRowsForRun);
// `invoices` is queried for the main listing (call 1) and, when there
// are cancelled rows, the replacements lookup (call 2).
callCount += 1;
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// loadCosts (#4) schema-guards each cost table. Default off so the
// revenue-only tests are unaffected; cost-side tests flip it on.
mockDbFn.schema = { hasTable: jest.fn(async () => costTablesPresent) };
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments and returns the mocked rows, so the raw() return value
// just needs to exist — a string is fine.
mockDbFn.raw = jest.fn((sql) => sql);
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
const taxReportService = require('../../src/services/taxReportService');
const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReportService._internal;
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
inboundRowsForRun = [];
expenseRowsForRun = [];
costTablesPresent = false;
callCount = 0;
mockDbFn.mockClear();
});
// ----- pure helpers ----------------------------------------------------
describe('grossUpLateFee', () => {
it('returns zeros for a zero or negative fee', () => {
expect(grossUpLateFee(0, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(-100, 7.7)).toEqual({ net: 0, vat: 0 });
expect(grossUpLateFee(null, 7.7)).toEqual({ net: 0, vat: 0 });
});
it('returns the whole fee as net when VAT rate is 0', () => {
expect(grossUpLateFee(2500, 0)).toEqual({ net: 2500, vat: 0 });
// Missing/invalid rate is treated the same.
expect(grossUpLateFee(2500, null)).toEqual({ net: 2500, vat: 0 });
});
it('splits a 25.00 CHF fee at 7.7% into net 23.21 + VAT 1.79', () => {
// 2500 / 1.077 = 2321.265… → rounds to 2321; 2500 - 2321 = 179.
expect(grossUpLateFee(2500, 7.7)).toEqual({ net: 2321, vat: 179 });
});
it('guarantees net + vat === gross input (no rounding drift)', () => {
for (const fee of [1, 2500, 9999, 12345, 250000]) {
for (const rate of [7.7, 8.1, 19, 20.5]) {
const { net, vat } = grossUpLateFee(fee, rate);
expect(net + vat).toBe(fee);
}
}
});
});
describe('computeReportedAmounts', () => {
it('returns stored amounts unchanged when late fee is zero', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 10770,
late_fee_amount_minor: 0,
vat_rate: 7.7,
});
expect(r).toEqual({ netMinor: 10000, vatMinor: 770, totalMinor: 10770 });
});
it('adds the late-fee net/vat split onto the stored net + vat', () => {
const r = computeReportedAmounts({
net_amount_minor: 10000,
vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 late fee
late_fee_amount_minor: 2500,
vat_rate: 7.7,
});
expect(r.netMinor).toBe(10000 + 2321);
expect(r.vatMinor).toBe(770 + 179);
expect(r.totalMinor).toBe(13270);
});
it('keeps total at the stored total even when late fee is present', () => {
// The stored total already includes the late fee — we never
// recompute it from net + vat in the report.
const r = computeReportedAmounts({
net_amount_minor: 50000,
vat_amount_minor: 4050,
total_amount_minor: 56550,
late_fee_amount_minor: 2500,
vat_rate: 8.1,
});
expect(r.totalMinor).toBe(56550);
});
});
describe('buildCustomerLabel', () => {
it('prefers company_name when present', () => {
expect(buildCustomerLabel({
customer_company_name: 'ACME GmbH',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
customer_email: 'anna@example.com',
})).toBe('ACME GmbH');
});
it('falls back to first + last name', () => {
expect(buildCustomerLabel({
customer_company_name: '',
customer_first_name: 'Anna',
customer_last_name: 'Beispiel',
})).toBe('Anna Beispiel');
});
it('falls back to display_name when no name parts', () => {
expect(buildCustomerLabel({
customer_display_name: 'Anna B.',
})).toBe('Anna B.');
});
it('falls back to email as a last resort', () => {
expect(buildCustomerLabel({ customer_email: 'anna@example.com' })).toBe('anna@example.com');
});
it('returns empty string when nothing usable is present', () => {
expect(buildCustomerLabel({})).toBe('');
});
});
// ----- getTaxReport ----------------------------------------------------
describe('getTaxReport', () => {
it('throws when from/to or currency are missing', async () => {
await expect(taxReportService.getTaxReport({})).rejects.toThrow(/from.+to/);
await expect(taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31' }))
.rejects.toThrow(/currency/);
});
it('returns rows + totals for a clean period with one paid invoice', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'chf', // lowercase → coerced
});
expect(out.currency).toBe('CHF');
expect(out.rows).toHaveLength(1);
expect(out.rows[0]).toMatchObject({
invoiceNumber: 'R-2026-0001',
isCancelled: false,
customerLabel: 'ACME GmbH',
eventName: 'Wedding A',
netMinor: 10000,
vatMinor: 770,
totalMinor: 10770,
});
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(0);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
expect(out.period).toEqual({ from: '2026-01-01', to: '2026-03-31' });
});
it('keeps cancelled rows visible but excludes them from totals', async () => {
invoiceRowsForRun = [
{
id: 10, invoice_number: 'R-2026-0010', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
{
id: 11, invoice_number: 'R-2026-0011', issue_date: '2026-02-02',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: 10,
customer_company_name: 'ACME GmbH', customer_first_name: null, customer_last_name: null,
customer_display_name: null, customer_email: null, event_name: 'Wedding A',
},
];
// The supersedes lookup query: row 11 supersedes row 10.
replacementsRowsForRun = [{ replaces_invoice_id: 10, invoice_number: 'R-2026-0011' }];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toHaveLength(2);
const cancelled = out.rows.find((r) => r.invoiceNumber === 'R-2026-0010');
const replacement = out.rows.find((r) => r.invoiceNumber === 'R-2026-0011');
expect(cancelled.isCancelled).toBe(true);
expect(cancelled.replacedByInvoiceNumber).toBe('R-2026-0011');
expect(replacement.isCancelled).toBe(false);
// Totals: only the replacement counts.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.cancelledCount).toBe(1);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => {
// The real cancel-and-reissue flow produces THREE rows in the period:
// the cancelled original, its negative Storno (kind='storno', status='sent'),
// and the reissue. Totals must read the reissued amount, not 0.
invoiceRowsForRun = [
{
id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02',
currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7,
net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03',
currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: 20,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
];
replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }];
const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' });
expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail
// The negative storno must NOT net against the totals (the cancelled
// original is already excluded) — the reissued revenue stands.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-01',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'A', event_name: 'X',
},
{
id: 2, invoice_number: 'R-2026-0002', issue_date: '2026-01-02',
currency: 'CHF', status: 'paid', vat_rate: 8.1,
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'B', event_name: 'Y',
},
{
id: 3, invoice_number: 'R-2026-0003', issue_date: '2026-01-03',
currency: 'CHF', status: 'sent', vat_rate: 8.1,
net_amount_minor: 5000, vat_amount_minor: 405, total_amount_minor: 5405,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'C', event_name: 'Z',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.totalsByVatRate).toHaveLength(2);
// Sorted ascending by rate.
expect(out.totalsByVatRate[0]).toEqual({
vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770,
});
expect(out.totalsByVatRate[1]).toEqual({
vatRate: 8.1, netMinor: 25000, vatMinor: 2025, totalMinor: 27025,
});
expect(out.grandTotalNet).toBe(35000);
expect(out.grandTotalVat).toBe(2795);
expect(out.grandTotal).toBe(37795);
});
it('folds late fees into the reporting net + vat (gross-up per VAT rate)', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'overdue', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770,
total_amount_minor: 13270, // 10000 + 770 + 2500 fee
late_fee_amount_minor: 2500, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
// Late fee 2500 @ 7.7% → net 2321 + vat 179.
expect(out.rows[0].netMinor).toBe(12321);
expect(out.rows[0].vatMinor).toBe(949);
expect(out.rows[0].totalMinor).toBe(13270);
// Grand totals reflect the same gross-up math.
expect(out.grandTotalNet).toBe(12321);
expect(out.grandTotalVat).toBe(949);
expect(out.grandTotal).toBe(13270);
});
it('returns empty rows + zero totals when no invoices match the period', async () => {
invoiceRowsForRun = [];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.rows).toEqual([]);
expect(out.grandTotalNet).toBe(0);
expect(out.grandTotalVat).toBe(0);
expect(out.grandTotal).toBe(0);
expect(out.totalsByVatRate).toEqual([]);
expect(out.cancelledCount).toBe(0);
});
it('returns an empty cost side + zeroed summary when accounting tables are absent', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'X',
},
];
costTablesPresent = false; // no accounting migrations on this DB
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 });
expect(out.summary).toMatchObject({
incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770,
costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0,
resultNetMinor: 10000, resultGrossMinor: 10770,
// VAT registration unconfigured in the test DB → refuse to compute payable.
vatRegistrationConfigured: false, vatPayableMinor: null,
});
});
it('aggregates incoming invoices + expenses into the cost side and nets the result', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 100000, vat_amount_minor: 7700, total_amount_minor: 107700,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
},
];
costTablesPresent = true;
// Incoming supplier invoice: net 20000 + vat 1540 = 21540.
inboundRowsForRun = [
{
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
net_amount_minor: 20000, vat_amount_minor: 1540, total_amount_minor: 21540,
event_name: 'Wedding A',
},
];
// Internal expense (mileage, no VAT split): only a CHF base amount.
expenseRowsForRun = [
{
id: 9, created_at: '2026-02-01 12:00:00',
supplier_name: null, description: 'Travel', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'open', event_id: null,
original_currency: null, original_amount_minor: null, chf_amount_minor: 5000,
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null,
event_name: null,
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs.rows).toHaveLength(2);
// Incoming invoice mapped + booked to the event.
const incoming = out.costs.rows.find((r) => r.source === 'incoming');
expect(incoming).toMatchObject({
supplierLabel: 'Lab AG', eventName: 'Wedding A',
netMinor: 20000, vatMinor: 1540, totalMinor: 21540,
});
// Expense: no net/vat/gross → falls back to the CHF base as total,
// and (company-booked) event name blank.
const expense = out.costs.rows.find((r) => r.source === 'expense');
expect(expense).toMatchObject({
eventName: '', netMinor: 5000, vatMinor: 0, totalMinor: 5000,
});
expect(out.costs.totalNet).toBe(25000);
expect(out.costs.totalVat).toBe(1540);
expect(out.costs.totalGross).toBe(26540);
// Summary nets income against costs.
expect(out.summary).toMatchObject({
incomeNetMinor: 100000, incomeVatMinor: 7700, incomeGrossMinor: 107700,
costNetMinor: 25000, costVatMinor: 1540, costGrossMinor: 26540,
resultNetMinor: 75000, resultGrossMinor: 81160,
vatRegistrationConfigured: false, vatPayableMinor: null,
});
});
it('excludes declined/duplicate incoming invoices via the query filter (sanity on chain wiring)', async () => {
costTablesPresent = true;
inboundRowsForRun = []; // the whereNotIn filter is applied in SQL; here we assert empty → zeroed
expenseRowsForRun = [];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs.totalGross).toBe(0);
expect(out.summary.costGrossMinor).toBe(0);
});
});
// ----- export scope (income/cost split) --------------------------------
describe('export scope helpers', () => {
const { scopeLedger, normalizeScope } = taxReportService._internal;
const ledger = [
{ type: 'outgoing', reference: 'R-1' },
{ type: 'incoming', reference: 'IN-1' },
{ type: 'expense', reference: 'EXP-1' },
];
it('normalizeScope defaults unknown/empty to "all"', () => {
expect(normalizeScope('all')).toBe('all');
expect(normalizeScope('income')).toBe('income');
expect(normalizeScope('cost')).toBe('cost');
expect(normalizeScope('bogus')).toBe('all');
expect(normalizeScope(undefined)).toBe('all');
});
it('scopeLedger income keeps only outgoing rows', () => {
expect(scopeLedger(ledger, 'income').map((r) => r.type)).toEqual(['outgoing']);
});
it('scopeLedger cost keeps incoming + expense rows', () => {
expect(scopeLedger(ledger, 'cost').map((r) => r.type)).toEqual(['incoming', 'expense']);
});
it('scopeLedger all keeps everything; null-safe', () => {
expect(scopeLedger(ledger, 'all')).toHaveLength(3);
expect(scopeLedger(null, 'income')).toEqual([]);
});
});
describe('renderTaxReportCsv scope', () => {
beforeEach(() => {
costTablesPresent = true;
invoiceRowsForRun = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 8.1,
net_amount_minor: 100000, vat_amount_minor: 8100, total_amount_minor: 108100,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
}];
inboundRowsForRun = [{
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
event_name: 'Wedding A',
}];
});
it('income scope keeps the invoice row, drops the supplier cost row', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'income',
});
expect(content).toContain('R-2026-0001');
expect(content).not.toContain('Lab AG');
expect(filename).toContain('income_');
});
it('cost scope keeps the supplier row, drops the invoice row', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'cost',
});
expect(content).toContain('Lab AG');
expect(content).not.toContain('R-2026-0001');
expect(filename).toContain('cost_');
});
it('all scope (default) keeps both', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(content).toContain('R-2026-0001');
expect(content).toContain('Lab AG');
expect(filename).not.toMatch(/income_|cost_/);
});
});
@@ -0,0 +1,91 @@
/**
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
*
* Pins the contract that drives `adminDashboard.js` analytics route:
* - Returns null for 'none' / 'custom' / unset → route falls back to access_logs.
* - Returns an Umami adapter shape for provider='umami'.
* - Returns a Rybbit adapter shape for provider='rybbit'.
* - Back-compat: when `analytics_tracker_provider` is unset, infers
* 'umami' from the legacy `analytics_umami_enabled` flag.
* - Invalid provider strings fall through to the legacy back-compat path
* rather than crashing (defensive).
*/
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-tracker-fact-')), 'db.sqlite',
);
const { bootCrmDb } = require('../integration/helpers/crmDb');
const trackers = require('../../src/services/trackers');
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('app_settings').del();
});
async function setSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'analytics',
updated_at: new Date(),
});
}
describe('resolveAdapter (#663)', () => {
test('returns null when provider=\'none\'', async () => {
await setSetting('analytics_tracker_provider', 'none');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
await setSetting('analytics_tracker_provider', 'custom');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
await setSetting('analytics_umami_enabled', true);
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter).not.toBeNull();
expect(adapter.provider).toBe('umami');
});
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'umami');
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('umami');
});
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'rybbit');
await setSetting('analytics_rybbit_url', 'https://r.example');
await setSetting('analytics_rybbit_website_id', 'r-1');
await setSetting('analytics_rybbit_api_key', 'rk-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('rybbit');
});
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
// No legacy umami_enabled → resolves to null (= 'none')
expect(await trackers.resolveAdapter()).toBeNull();
});
});
@@ -0,0 +1,112 @@
/**
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
* the old `umamiClient.test.js` from #662 — same contract, new shape).
*
* Pins the same 10 cases that protected the original implementation: missing
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
*/
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete (back-compat path)', async () => {
const a = buildAdapter({});
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
const b = buildAdapter({ baseUrl: 'https://u' });
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
mockJson([{ x: 'desktop', y: 10 }]);
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
await a.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toBe(
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
);
expect(init.headers['x-umami-api-key']).toBe('secret');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ x: 'desktop', y: 1 }]);
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
});
test('normalises { x, y } payload into integer percentages', async () => {
mockJson([
{ x: 'desktop', y: 60 },
{ x: 'mobile', y: 30 },
{ x: 'tablet', y: 10 },
]);
const a = buildAdapter(valid);
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
mockJson([
{ x: 'desktop', y: 50 },
{ x: 'laptop', y: 20 },
{ x: 'mobile', y: 30 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
});
test('drops unknown buckets (no silent miscategorisation)', async () => {
mockJson([
{ x: 'desktop', y: 80 },
{ x: 'mobile', y: 20 },
{ x: 'unknown-future-bucket', y: 100 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on non-2xx', async () => {
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on invalid JSON', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on network error', async () => {
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -0,0 +1,101 @@
/**
* Coverage for the changelog aggregation introduced for #567.
*
* `getReleasesSince` is what feeds the update-available modal so the
* admin can see release notes for every version between their current
* version and latest. The cases below pin:
*
* - Strictly-newer filtering (the running version itself never
* appears in the list).
* - Channel filtering (a stable user does not see beta releases,
* and vice versa).
* - Empty-result fallback when the GitHub fetch returns nothing
* (cached null from `fetchAvailableVersions`).
*
* We mock axios's GitHub response — these tests are pure logic, no
* network. Cache is cleared between cases via the service's exported
* `clearCache()` to avoid bleed.
*/
jest.mock('axios');
const axios = require('axios');
const { getReleasesSince, clearCache } = require('../../src/services/updateCheckService');
function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
return {
tag_name: tag,
name: tag,
body,
published_at: publishedAt,
html_url: `https://github.com/PicPeak/picpeak/releases/tag/${tag}`,
};
}
describe('updateCheckService.getReleasesSince', () => {
beforeEach(() => {
clearCache();
axios.get.mockReset();
});
it('returns only releases strictly newer than current, for the requested channel', async () => {
axios.get.mockResolvedValue({
data: [
release('v3.55.0', 'stable notes 3.55.0'),
release('v3.54.0', 'stable notes 3.54.0'),
release('v3.43.1', 'stable notes 3.43.1 — the running version, should be excluded'),
release('v3.43.0'),
release('v3.55.0-beta.0', 'beta notes — wrong channel, excluded'),
release('v3.54.0-beta.5'),
],
});
const result = await getReleasesSince('3.43.1', 'stable');
expect(result.map((r) => r.version)).toEqual(['3.55.0', '3.54.0']);
// Body + html_url + publishedAt are preserved so the modal can render them
expect(result[0]).toMatchObject({
version: '3.55.0',
tag: 'v3.55.0',
name: 'v3.55.0',
body: 'stable notes 3.55.0',
htmlUrl: 'https://github.com/PicPeak/picpeak/releases/tag/v3.55.0',
});
});
it('returns only beta releases for a beta user', async () => {
axios.get.mockResolvedValue({
data: [
release('v3.55.0-beta.0'),
release('v3.54.7-beta.0'),
release('v3.55.0'), // stable — wrong channel for a beta user
release('v3.54.6-beta.0'),
],
});
const result = await getReleasesSince('3.54.6-beta.0', 'beta');
// Strictly newer beta-channel only — does not include 3.55.0 stable
// even though it's a newer release, because the beta channel user
// wants to see beta releases (which can include releases that
// landed on the beta line after the stable cut).
expect(result.map((r) => r.version)).toEqual(['3.55.0-beta.0', '3.54.7-beta.0']);
});
it('returns empty array when GitHub fetch fails (e.g. rate-limited)', async () => {
axios.get.mockRejectedValue(new Error('API rate limit exceeded'));
const result = await getReleasesSince('3.43.1', 'stable');
expect(result).toEqual([]);
});
it('returns empty array when the user is already on the latest version', async () => {
axios.get.mockResolvedValue({
data: [release('v3.55.0'), release('v3.54.0')],
});
const result = await getReleasesSince('3.55.0', 'stable');
expect(result).toEqual([]);
});
});
@@ -0,0 +1,146 @@
/**
* Coverage for the activate + delete admin-user actions introduced as
* the #574 UI follow-up.
*
* Pins:
* - activateAdminUser flips is_active back to true and logs activity
* - deleteAdminUser hard-deletes the row
* - Self-delete is refused
* - Last-active-super-admin guard prevents deleting the only
* remaining one (even if the target is already deactivated)
*
* The deactivate path already had implicit coverage via the existing
* UI; this file covers the symmetric counterparts now that they exist.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-user-act-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'user-act-test-secret';
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
const userManagementService = require('../../src/services/userManagementService');
describe('userManagementService — activate + delete (#574 follow-up)', () => {
let db;
let cleanup;
let actorId; // The admin performing the actions (must be active + super_admin)
let targetId; // The admin we'll deactivate / reactivate / delete
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: actorId } = await seedMinimal(db));
await assignAdminRole(db, actorId, 'super_admin');
// Second admin to be the target of our actions. Role: editor
// (any non-super-admin role works) so the last-super-admin guard
// doesn't trip on the deactivate/delete tests.
const editor = await db('roles').where({ name: 'editor' }).first();
const targetInsert = await db('admin_users').insert({
username: 'target', email: 'target@example.com',
password_hash: 'x', role_id: editor?.id || null,
is_active: 1, created_at: new Date(),
}).returning('id');
targetId = targetInsert[0]?.id ?? targetInsert[0];
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('activateAdminUser', () => {
it('flips is_active back to true when target is deactivated', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.activateAdminUser(targetId, actorId);
const row = await db('admin_users').where({ id: targetId }).first();
expect(row.is_active === true || row.is_active === 1).toBe(true);
});
it('is a no-op when target is already active', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 1, updated_at: new Date('2000-01-01') });
const before = await db('admin_users').where({ id: targetId }).first();
await userManagementService.activateAdminUser(targetId, actorId);
const after = await db('admin_users').where({ id: targetId }).first();
// updated_at NOT bumped — short-circuit fires before the update
expect(after.updated_at).toEqual(before.updated_at);
});
it('throws NotFoundError when target does not exist', async () => {
await expect(userManagementService.activateAdminUser(99999, actorId))
.rejects.toThrow(/not found|admin user/i);
});
it('writes an admin_user_activated activity log entry', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.activateAdminUser(targetId, actorId);
const log = await db('activity_logs')
.where({ activity_type: 'admin_user_activated' })
.orderBy('id', 'desc')
.first();
expect(log).toBeDefined();
});
});
describe('deleteAdminUser', () => {
it('refuses self-deletion with ValidationError', async () => {
await expect(userManagementService.deleteAdminUser(actorId, actorId))
.rejects.toThrow(/own account/i);
// Actor still exists
const row = await db('admin_users').where({ id: actorId }).first();
expect(row).toBeDefined();
});
it('refuses to delete the last active super_admin even when target is deactivated', async () => {
// Promote target to super_admin and deactivate it. Now there's
// only ONE active super_admin (the actor). Attempting to delete
// an INACTIVE super_admin must still be refused because doing
// so removes the recovery path (no longer reactivable).
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const deactivatedSuperInsert = await db('admin_users').insert({
username: 'inactive-super', email: 'inactive-super@example.com',
password_hash: 'x', role_id: superRole.id, is_active: 0,
created_at: new Date(),
}).returning('id');
const inactiveSuperId = deactivatedSuperInsert[0]?.id ?? deactivatedSuperInsert[0];
// Actor is the ONLY active super_admin. Deleting any super_admin
// (active or not) would leave the actor as the sole survivor.
// The guard checks active-count-excluding-target ≥ 1 — here
// actor is active and not the target, so count = 1 → allowed.
await userManagementService.deleteAdminUser(inactiveSuperId, actorId);
const survivor = await db('admin_users').where({ id: inactiveSuperId }).first();
expect(survivor).toBeUndefined();
});
it('hard-deletes the row when guards pass', async () => {
// Recreate the target since previous tests may have left it active
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.deleteAdminUser(targetId, actorId);
const row = await db('admin_users').where({ id: targetId }).first();
expect(row).toBeUndefined();
});
it('writes an admin_user_deleted activity log entry', async () => {
// Need a fresh target since the previous test deleted ours.
const editor = await db('roles').where({ name: 'editor' }).first();
const inserted = await db('admin_users').insert({
username: 'about-to-go', email: 'about-to-go@example.com',
password_hash: 'x', role_id: editor?.id || null,
is_active: 0, created_at: new Date(),
}).returning('id');
const id = inserted[0]?.id ?? inserted[0];
await userManagementService.deleteAdminUser(id, actorId);
const log = await db('activity_logs')
.where({ activity_type: 'admin_user_deleted' })
.orderBy('id', 'desc')
.first();
expect(log).toBeDefined();
});
});
});
@@ -0,0 +1,58 @@
/**
* Pins the date-merge fix in `adminDashboard.js` /analytics route
* (#661 Bug A). The merge previously failed on Postgres because pg's
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
* returns a string — the old `dateObj.date === row.date` comparison
* was false on Postgres so chartData stayed all-zero even with traffic.
*
* We test the normalisation helper here in isolation. The route-level
* integration is covered by the existing dashboard route test.
*/
// The helper is internal to the route file; reimport via a small wrapper
// so we don't need to export everything publicly.
const path = require('path');
const fs = require('fs');
const ROUTE_SRC = fs.readFileSync(
path.join(__dirname, '../../src/routes/adminDashboard.js'),
'utf8',
);
// Tiny evaluator that grabs the normaliseDateKey function definition from
// the route source so the test pins the actual shipping implementation,
// not a copy.
function extractNormaliseDateKey() {
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
// eslint-disable-next-line no-new-func
return new Function(`${match[0]}; return normaliseDateKey;`)();
}
const normaliseDateKey = extractNormaliseDateKey();
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
test('passes through a YYYY-MM-DD string unchanged', () => {
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
});
test('slices off a time component on a longer ISO string', () => {
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
});
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
const d = new Date('2026-06-22T12:34:56Z');
expect(normaliseDateKey(d)).toBe('2026-06-22');
});
test('returns null for null / undefined / empty', () => {
expect(normaliseDateKey(null)).toBeNull();
expect(normaliseDateKey(undefined)).toBeNull();
expect(normaliseDateKey('')).toBeNull();
});
test('coerces unexpected types via String() to avoid throwing', () => {
// We don't expect to receive a number from either driver, but the
// helper should not crash if it does — date merge will simply miss.
expect(normaliseDateKey(20260622)).toBe('20260622');
});
});
@@ -0,0 +1,238 @@
/**
* Unit tests for the per-weekday business-hours floor (migration 114).
* Exercises the pure snap logic against a fixed IANA zone so the results
* don't drift with the CI box's local timezone.
*
* All scenarios use Europe/Zurich (the regulatory-scope default).
*/
const {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal,
} = require('../../src/utils/businessHours');
const TZ = 'Europe/Zurich';
// MonFri 09:0018:00, weekend closed. Plain string-block storage shape.
const STANDARD = {
'1': [{ start: '09:00', end: '18:00' }],
'2': [{ start: '09:00', end: '18:00' }],
'3': [{ start: '09:00', end: '18:00' }],
'4': [{ start: '09:00', end: '18:00' }],
'5': [{ start: '09:00', end: '18:00' }],
'6': [],
'7': [],
};
// MonFri with a lunch break (09:0012:00, 13:0018:00).
const LUNCH = {
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'6': [],
'7': [],
};
const cfg = (schedule, overrides = {}) => ({
enabled: true,
timezone: TZ,
schedule,
...overrides,
});
// Build a UTC instant from a Zurich wall-clock so the assertions read in
// local terms. Reuses the module's own converter (covered separately).
function zurich(y, mo, d, hh, mi) {
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
}
function partsOf(date) {
const p = _internal.getZonedParts(date, TZ);
return [p.y, p.mo, p.d, p.hh, p.mi];
}
describe('parseHHMM / minutesToHHMM', () => {
it('parses valid times to minutes', () => {
expect(parseHHMM('09:00')).toBe(540);
expect(parseHHMM('00:00')).toBe(0);
expect(parseHHMM('23:59')).toBe(1439);
});
it('rejects malformed input', () => {
expect(parseHHMM('9:00')).toBeNull();
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('')).toBeNull();
expect(parseHHMM(null)).toBeNull();
});
it('round-trips minutesToHHMM', () => {
expect(minutesToHHMM(540)).toBe('09:00');
expect(minutesToHHMM(0)).toBe('00:00');
expect(minutesToHHMM(1439)).toBe('23:59');
});
});
describe('normaliseSchedule', () => {
it('parses, sorts, and drops invalid blocks', () => {
const out = normaliseSchedule({
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
});
expect(out['1']).toEqual([
{ start: '09:00', end: '12:00' },
{ start: '13:00', end: '18:00' },
]);
expect(out['2']).toEqual([]);
expect(out['3']).toEqual([]);
expect(out['7']).toEqual([]);
});
it('accepts [start,end] pair blocks and a JSON string', () => {
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
});
it('garbage input → all-empty week', () => {
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
});
});
describe('snapToBusinessHours — single window (MonFri 09:0018:00)', () => {
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
// 2026-06-02 is a Tuesday.
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('weekday inside window (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
it('Sunday 14:00 → Monday 09:00', () => {
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
// 2026-06-05 is a Friday.
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
const input = zurich(2026, 6, 2, 9, 0);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — lunch break (09:0012:00, 13:0018:00)', () => {
it('morning block (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('afternoon block (Tue 17:59) → unchanged', () => {
const input = zurich(2026, 6, 2, 17, 59);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('after close (Tue 19:00) → next day 09:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — per-day differing hours', () => {
const PERDAY = {
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
'2': [], // Tue closed
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
'4': [], '5': [], '6': [], '7': [],
};
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
});
describe('snapToBusinessHours — passthrough cases', () => {
it('floor disabled → unchanged even when outside hours', () => {
const input = zurich(2026, 6, 2, 2, 11);
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
.toBe(input.getTime());
});
it('empty schedule → unchanged (nothing to snap to)', () => {
const empty = normaliseSchedule(null);
const input = zurich(2026, 6, 7, 14, 0);
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
});
it('non-Date / invalid input is passed through untouched', () => {
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
const bad = new Date('not-a-date');
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
});
});
describe('_internal round-trips', () => {
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
const p = _internal.getZonedParts(d, TZ);
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
});
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
});
});
+19
View File
@@ -0,0 +1,19 @@
const { isUniqueViolation } = require('../../src/utils/dbErrors');
describe('isUniqueViolation (PR #622 blocker 2 race-safety detector)', () => {
it('true for Postgres SQLSTATE 23505', () => {
expect(isUniqueViolation({ code: '23505' })).toBe(true);
});
it('true for node-sqlite3 SQLITE_CONSTRAINT code', () => {
expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(true);
});
it('true for a better-sqlite3 "UNIQUE constraint failed" message', () => {
expect(isUniqueViolation({ message: 'UNIQUE constraint failed: received_emails.message_id' })).toBe(true);
});
it('false for unrelated errors and nullish', () => {
expect(isUniqueViolation({ code: '23503' })).toBe(false); // FK violation
expect(isUniqueViolation({ message: 'connection refused' })).toBe(false);
expect(isUniqueViolation(null)).toBe(false);
expect(isUniqueViolation(undefined)).toBe(false);
});
});
@@ -0,0 +1,57 @@
/**
* Regression coverage for the identity-preserving email normalization
* options (#574).
*
* express-validator's `.normalizeEmail()` applies provider-specific
* canonicalization by default — Gmail dot-stripping, +tag stripping,
* googlemail → gmail folding, etc. That breaks identity because login
* lookups expect the address as the user was invited with, not the
* canonicalized form.
*
* The tests below run validator.js's `normalizeEmail` (the same
* implementation express-validator delegates to) through the
* `IDENTITY_PRESERVING_NORMALIZE_EMAIL` options object and pin the
* behaviour we depend on:
* - dots preserved on Gmail
* - +tags preserved on Gmail / Outlook / Yahoo / iCloud
* - googlemail.com domain preserved (not folded to gmail.com)
* - local-part lowercased (still the default — safe and consistent)
*/
const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../src/utils/emailNormalization');
const norm = (email) => validator.normalizeEmail(email, IDENTITY_PRESERVING_NORMALIZE_EMAIL);
describe('IDENTITY_PRESERVING_NORMALIZE_EMAIL', () => {
it('preserves dots in the Gmail local-part (the #574 root cause)', () => {
expect(norm('john.doe@gmail.com')).toBe('john.doe@gmail.com');
expect(norm('j.o.h.n@gmail.com')).toBe('j.o.h.n@gmail.com');
});
it('preserves Gmail +tags (subaddresses)', () => {
expect(norm('john.doe+invoices@gmail.com')).toBe('john.doe+invoices@gmail.com');
});
it('does not fold googlemail.com to gmail.com', () => {
expect(norm('john.doe@googlemail.com')).toBe('john.doe@googlemail.com');
});
it('preserves Outlook +tags', () => {
expect(norm('jane+work@outlook.com')).toBe('jane+work@outlook.com');
});
it('preserves Yahoo -tags', () => {
expect(norm('jane-work@yahoo.com')).toBe('jane-work@yahoo.com');
});
it('preserves iCloud +tags', () => {
expect(norm('jane+receipts@icloud.com')).toBe('jane+receipts@icloud.com');
});
it('lowercases the local-part (default behaviour we keep)', () => {
// all_lowercase defaults true in validator.js. Local-parts are
// case-insensitive in practice on every major provider, and
// lowercasing keeps login lookup consistent.
expect(norm('John.Doe@Gmail.com')).toBe('john.doe@gmail.com');
});
});
@@ -0,0 +1,240 @@
/**
* Unit tests for the per-guest favorite/like cap (#655).
*
* Pins the contract of `feedbackService.submitFeedback` around the cap:
* - null / 0 cap means unlimited (back-compat for installs that don't
* enable the feature).
* - At-cap ADD returns `{ limit_reached, limit, current_count }` rather
* than inserting — the route layer translates that into the structured
* 403 the UI listens for.
* - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state.
* A guest at 10/10 can still free a slot.
* - Limit reduction (admin lowers 20 → 10 while a guest has 15 already)
* grandfathers existing rows — new adds blocked, removals always allowed.
* - Caps are per-feedback-type: filling the favorite quota doesn't block
* likes on the same photo, and vice versa.
*/
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-feedback-limit-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const EVENT_SLUG = 'cap-test-event';
const GUEST_A = 'guest-a-identifier';
const GUEST_B = 'guest-b-identifier';
let db;
let cleanup;
let eventId;
let photoIds;
async function setEventFeedbackSettings(overrides) {
const base = {
feedback_enabled: 1,
allow_ratings: 1,
allow_likes: 1,
allow_comments: 0,
allow_favorites: 1,
require_name_email: 0,
moderate_comments: 0,
show_feedback_to_guests: 1,
identity_mode: 'simple',
max_favorites_per_guest: null,
max_likes_per_guest: null,
...overrides,
};
const existing = await db('event_feedback_settings').where('event_id', eventId).first();
if (existing) {
await db('event_feedback_settings').where('event_id', eventId).update(base);
} else {
await db('event_feedback_settings').insert({
event_id: eventId,
...base,
created_at: new Date(),
updated_at: new Date(),
});
}
}
async function favorite(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'favorite',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
async function like(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Cap Test',
event_date: '2026-06-22',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${EVENT_SLUG}/share`,
share_token: 'cap-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
// Seed 15 photos so we can test caps comfortably up to that count.
photoIds = [];
for (let i = 1; i <= 15; i += 1) {
const r = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/cap/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(r[0]?.id ?? r[0]);
}
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where('event_id', eventId).del();
});
describe('per-guest favorite cap (#655)', () => {
test('null cap = unlimited (back-compat for installs without #655)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: null });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
expect(r.created).toBe(true);
}
});
test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 0 });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
}
});
test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
const r = await favorite(id);
expect(r.created).toBe(true);
}
const r11 = await favorite(photoIds[10]);
expect(r11.limit_reached).toBe(true);
expect(r11.limit).toBe(10);
expect(r11.current_count).toBe(10);
expect(r11.feedback_type).toBe('favorite');
});
test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
for (const id of photoIds.slice(0, 5)) {
await favorite(id);
}
const blocked = await favorite(photoIds[5]);
expect(blocked.limit_reached).toBe(true);
// Un-favorite one — toggle off path returns { removed: true }
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
// Now the previously-blocked slot fits
const after = await favorite(photoIds[5]);
expect(after.created).toBe(true);
});
test('limit reduction grandfathers existing rows; new adds blocked', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
await favorite(id);
}
// Admin lowers the cap to 5 while the guest already has 10
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
// Existing 10 stay
const count = await db('photo_feedback')
.where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A })
.count('* as c').first();
expect(parseInt(count.c, 10)).toBe(10);
// New adds blocked
const blocked = await favorite(photoIds[10]);
expect(blocked.limit_reached).toBe(true);
expect(blocked.limit).toBe(5);
expect(blocked.current_count).toBe(10);
// Removals still allowed
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
});
test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 3 });
for (const id of photoIds.slice(0, 3)) {
await favorite(id, GUEST_A);
}
expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true);
// Guest B starts at 0
for (const id of photoIds.slice(0, 3)) {
const r = await favorite(id, GUEST_B);
expect(r.created).toBe(true);
}
expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true);
});
});
describe('per-guest like cap (#655)', () => {
test('favorite cap does NOT block likes on the same photo (per-type)', async () => {
await setEventFeedbackSettings({
max_favorites_per_guest: 3,
max_likes_per_guest: null,
});
for (const id of photoIds.slice(0, 3)) {
await favorite(id);
}
expect((await favorite(photoIds[3])).limit_reached).toBe(true);
// Likes still unlimited
for (const id of photoIds.slice(0, 10)) {
const r = await like(id);
expect(r.created).toBe(true);
}
});
test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => {
await setEventFeedbackSettings({ max_likes_per_guest: 2 });
await like(photoIds[0]);
await like(photoIds[1]);
const r = await like(photoIds[2]);
expect(r.limit_reached).toBe(true);
expect(r.feedback_type).toBe('like');
expect(r.limit).toBe(2);
expect(r.current_count).toBe(2);
});
});
@@ -0,0 +1,120 @@
/**
* Pure-function tests for the slug validator in galleryShortUrlService.
* The validator is the security boundary for the `/s/<slug>` public
* route — bad shapes leak into a UNIQUE column that's used in URLs
* without further escaping, so the rules need to be tight.
*/
// Provide a minimal db stub so requiring the service doesn't crash —
// the validator path doesn't touch the DB.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn().mockResolvedValue(false),
}));
const {
validateSlug,
_RESERVED_SLUGS,
} = require('../../src/services/galleryShortUrlService');
describe('validateSlug', () => {
describe('accepts', () => {
test.each([
'sofia-graduation',
'sofia',
'a', // single char (alphanumeric)
'1', // single digit
'abc123',
'123-abc',
'sofia-2026-06-05',
'sofia-2026',
'a-b-c-d',
'wedding-2026',
'xK7p2'.toLowerCase(), // lowercase 5-char
'a'.repeat(64), // exactly at the limit
])('%j', (slug) => {
expect(validateSlug(slug)).toBeNull();
});
});
describe('rejects', () => {
test.each([
['', 'cannot be empty'],
[' ', 'cannot be empty'], // trimmed → empty
['-sofia', 'lowercase letters'], // leading hyphen
['sofia-', 'lowercase letters'], // trailing hyphen
['Sofia', 'lowercase letters'], // uppercase
['sofia_graduation', 'lowercase letters'], // underscore
['sofia.graduation', 'lowercase letters'], // dot
['sofia graduation', 'lowercase letters'], // space
['sofia/graduation', 'lowercase letters'], // slash (path traversal vector)
['sofia%20graduation', 'lowercase letters'],
['a'.repeat(65), 'at most 64'], // one over limit
])('%j → %s', (slug, expectedReason) => {
const result = validateSlug(slug);
expect(result).not.toBeNull();
expect(result.toLowerCase()).toContain(expectedReason);
});
test('null', () => {
expect(validateSlug(null)).toContain('must be a string');
});
test('undefined', () => {
expect(validateSlug(undefined)).toContain('must be a string');
});
test('number', () => {
expect(validateSlug(42)).toContain('must be a string');
});
test('object', () => {
expect(validateSlug({})).toContain('must be a string');
});
});
describe('reserved slugs', () => {
test.each([
'admin',
'api',
'auth',
'gallery',
'og',
'health',
's', // can't shadow the shortener itself
'login',
'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too
])('reserves %j', (slug) => {
expect(_RESERVED_SLUGS.has(slug)).toBe(true);
});
test('"admin" → rejected with "reserved" reason', () => {
// validateSlug short-circuits at the regex for slugs containing
// dots (favicon.ico fails the regex first). Test a clean
// alphanumeric reserved word.
const result = validateSlug('admin');
expect(result).toBe('short_slug is reserved');
});
});
describe('path-traversal + URL-injection vectors are rejected at the regex', () => {
test.each([
'../etc/passwd',
'foo/../bar',
'foo?query=1',
'foo#fragment',
'foo&bar',
'foo bar',
'foo<script>',
'foo>',
'foo"',
'foo\'',
'foo;rm -rf',
])('%j', (slug) => {
expect(validateSlug(slug)).not.toBeNull();
});
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Tests for the ISO 13616 IBAN validator.
*
* Reference IBANs sourced from the SWIFT IBAN Registry "Example" section
* — they are publicly published sample values used by every IBAN
* implementation as test vectors. NOT real account numbers.
*/
const { validateIban, _internal } = require('../../src/utils/iban');
describe('validateIban', () => {
it('accepts a canonical Swiss IBAN', () => {
const out = validateIban('CH9300762011623852957');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
expect(out.reason).toBeUndefined();
});
it('accepts a Liechtenstein IBAN', () => {
expect(validateIban('LI21088100002324013AA').valid).toBe(true);
});
it('accepts a German IBAN', () => {
expect(validateIban('DE89370400440532013000').valid).toBe(true);
});
it('accepts an Austrian IBAN', () => {
expect(validateIban('AT611904300234573201').valid).toBe(true);
});
it('accepts a British IBAN with alphanumeric BBAN', () => {
expect(validateIban('GB82WEST12345698765432').valid).toBe(true);
});
it('normalises spaces and lowercase input', () => {
const out = validateIban(' ch93 0076 2011 6238 5295 7 ');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
});
it('normalises mixed-case input', () => {
const out = validateIban('ch9300762011623852957');
expect(out.valid).toBe(true);
expect(out.normalized).toBe('CH9300762011623852957');
});
it('rejects an empty / null / undefined value', () => {
expect(validateIban('').reason).toBe('EMPTY');
expect(validateIban(' ').reason).toBe('EMPTY');
expect(validateIban(null).reason).toBe('EMPTY');
expect(validateIban(undefined).reason).toBe('EMPTY');
});
it('rejects a malformed string (numbers in the country slot)', () => {
const out = validateIban('12930076201162385295');
expect(out.valid).toBe(false);
expect(out.reason).toBe('FORMAT');
});
it('rejects a too-short string', () => {
expect(validateIban('CH93').reason).toBe('FORMAT');
});
it('rejects a too-long string (over 34 chars)', () => {
// 35 chars: pads beyond the ISO 13616 max
expect(validateIban('CH9300762011623852957XXXXXXXXXXXXXX').reason).toBe('FORMAT');
});
it('rejects a known-country IBAN with the wrong length', () => {
// CH must be 21 chars; this one is 22.
const out = validateIban('CH9300762011623852957X');
expect(out.valid).toBe(false);
expect(out.reason).toBe('LENGTH');
});
it('rejects an IBAN with a broken checksum', () => {
// Same shape, last digit altered.
const out = validateIban('CH9300762011623852950');
expect(out.valid).toBe(false);
expect(out.reason).toBe('CHECKSUM');
});
it('rejects an IBAN with internally invalid characters', () => {
expect(validateIban('CH93007620!1623852957').reason).toBe('FORMAT');
});
it('accepts an unknown-country IBAN that meets the generic length range', () => {
// Made-up country code "ZZ" — not in IBAN_LENGTHS but the
// structural regex passes if length is in [15, 34] and the
// checksum holds. Build a checksum-valid string:
//
// Format: ZZ + check + BBAN. We don't have a real ZZ template
// so this test just confirms unknown country codes route
// through the fallback length check rather than failing on
// LENGTH outright. A checksum-failing ZZ value will hit
// CHECKSUM, not LENGTH, which is the assertion below.
const out = validateIban('ZZ00ABCDEFGHIJKLMNOP');
expect(out.valid).toBe(false);
expect(out.reason).toBe('CHECKSUM'); // not LENGTH
});
});
describe('mod97', () => {
it('returns 1 for the canonical CH test vector', () => {
expect(_internal.mod97('CH9300762011623852957')).toBe(1);
});
it('returns something other than 1 for a tampered IBAN', () => {
expect(_internal.mod97('CH9300762011623852950')).not.toBe(1);
});
});
describe('IBAN_LENGTHS table', () => {
it('has the expected lengths for the most common European countries', () => {
// Sanity check that the table didn't drift if someone edits it.
expect(_internal.IBAN_LENGTHS.CH).toBe(21);
expect(_internal.IBAN_LENGTHS.DE).toBe(22);
expect(_internal.IBAN_LENGTHS.AT).toBe(20);
expect(_internal.IBAN_LENGTHS.LI).toBe(21);
expect(_internal.IBAN_LENGTHS.FR).toBe(27);
expect(_internal.IBAN_LENGTHS.IT).toBe(27);
expect(_internal.IBAN_LENGTHS.GB).toBe(22);
expect(_internal.IBAN_LENGTHS.NL).toBe(18);
});
});

Some files were not shown because too many files have changed in this diff Show More