Commit Graph

596 Commits

Author SHA1 Message Date
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 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 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
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
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
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
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
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 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 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
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
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
github-actions[bot] fd8ee5d52c chore(beta): release 3.55.0-beta.0 2026-05-27 13:53:11 +00: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
github-actions[bot] 54b185db47 chore(beta): release 3.54.7-beta.0 2026-05-26 20:54:29 +00: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 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 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 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
github-actions[bot] 08fa2e9b63 chore(beta): release 3.54.6-beta.0 2026-05-25 20:19:09 +00:00
Paul Nothaft 1b521e761c fix(api/v1): accept color_theme + create feedback row on event create (#550)
POST /v1/events was a strict subset of the admin create path: it did not
accept color_theme on the body, and it skipped the event_feedback_settings
insert that adminEvents.js does. Two visible bugs followed.

1. Editing an API-created event in the admin UI snapped the theme picker
   to GALLERY_THEME_PRESETS.default (EventDetailsPage.tsx falls through to
   the default preset when event.color_theme is falsy), and saving wrote
   that default back. Inherited themes were silently clobbered.

2. The "Enable Guest Feedback by default" admin setting (#520) did not
   apply to API-created events. With no event_feedback_settings row the
   gallery UI reads feedback as off, regardless of
   event_default_feedback_enabled.

Fix mirrors the admin path:

  - color_theme accepted on the request body (optional, persisted as-is —
    preset name or JSON-encoded ThemeConfig, same shape adminEvents
    stores).
  - feedback_enabled accepted on the request body; when omitted, falls
    back to the event_default_feedback_enabled global setting (same
    behaviour adminEvents.js:511-520 implements via readBooleanSetting).
  - event_feedback_settings row inserted when feedback resolves to true,
    using the same sub-flag defaults as the admin form (everything on
    except require_name_email).

OpenAPI JSDoc updated so docs.picpeak.app picks up the new fields.

Tests cover all four scenarios — explicit color_theme persisted, JSON
theme persisted verbatim, explicit feedback_enabled creates the row,
omitted feedback_enabled honours the global setting, and a validator
regression for non-boolean feedback_enabled.
2026-05-25 10:29:33 +02:00
github-actions[bot] 793aa1247e chore(beta): release 3.54.5-beta.0 2026-05-22 12:41:21 +00:00
github-actions[bot] 955ada4945 chore(beta): release 3.54.4-beta.0 2026-05-21 08:24:22 +00:00
github-actions[bot] c1e8e0d73a chore(beta): release 3.54.3-beta.0 2026-05-21 08:22:26 +00:00
github-actions[bot] 03cf09a7bf chore(beta): release 3.54.2-beta.0 2026-05-20 14:49:19 +00:00
github-actions[bot] baf5f27fb4 chore(beta): release 3.54.1-beta.0 2026-05-20 06:53:31 +00:00
paul 8b72721812 fix(public-site): honor dark theme surface colors 2026-05-20 08:48:40 +02:00
github-actions[bot] 085a2e5fed chore(beta): release 3.54.0-beta.0 2026-05-20 06:24:22 +00:00
Paul Nothaft 8f0108ce23 feat(install): skip legacy chain when modern bootstrap fingerprint detected (#530)
Refined from the original #530 framing after a dry-run uncovered that the
"bootstrap vs migration chain" diff produces mostly noise — most of the
~200 lines of difference are expected (migrations add new tables and
columns over time). initializeDatabase() isn't a parallel path that
diverges from migrations; it's invoked by migration 001 itself, so every
normal install/upgrade runs both.

The genuine drift hazard surfaced during the dry-run: a DB with the
modern bootstrap tables but an empty `migrations` table (which happens
when a backup was restored that lost the migrations table, or someone
invoked initializeDatabase() outside the runner, or the DB was moved
between systems without copying the migrations row) fails to upgrade.

Failure mode:
  1. detectExistingSchema sees the bootstrap tables + empty migrations,
     treats it as an "existing deployment".
  2. Runs the legacy chain first.
  3. legacy/008 renames email_templates.subject → subject_en.
  4. core/029 (later in the chain) inserts email templates referencing
     the pre-rename `subject` column.
  5. Postgres rejects: column "subject" doesn't exist; subject_en is
     NOT NULL with no default.

Fresh installs avoid this because they only run core/* (and core/059
handles the rename AFTER core/029 has inserted). Real legacy upgrades
avoid it because their migrations table already records legacy/008–028
as applied historically.

Fix in detectExistingSchema:
  - Detect the modern bootstrap fingerprint (photo_categories + cms_pages
    both present, which initializeDatabase produces as part of the
    consolidated post-004-era bootstrap).
  - When matched, enumerate every file in migrations/legacy/ and mark
    each as applied. This puts the recovery state on the same code path
    fresh installs use — only core migrations run, in core order.
  - Real legacy upgrades that already have entries in the migrations
    table hit no-op markings (markMigrationAsApplied skips duplicates),
    so their behaviour is unchanged.

New CI workflow (`.github/workflows/schema-drift.yml`):
  - Boots fresh postgres.
  - Seeds via `node -e \"require('./src/database/db').initializeDatabase()\"`
    — reproduces the recovery state in one line.
  - Runs `npm run migrate:safe`.
  - Asserts: precondition (bootstrap fingerprint + empty migrations
    table), migrate:safe exits 0, final schema has ≥40 tables (soft floor,
    not exact pin so future migrations don't force workflow edits),
    legacy migrations marked applied (confirms the fingerprint check
    actually fired vs. the chain silently bailing).
  - Triggers only on PRs that touch backend/migrations/**,
    src/database/db.js, knexfile.js, or this workflow.

Manually verified end-to-end before this commit:
  Before fix:  migrate:safe dies at core/029 with NOT NULL violation
               on email_templates.subject_en (17/48 tables present).
  After fix:   82 migrations applied + 27 marked applied = 109 total,
               final state has all 48 tables matching fresh-install.

Issue body in #530 has been updated to match this refined scope.

Refs: #530, #484, #519
2026-05-19 22:48:54 +02:00
github-actions[bot] c042a33431 chore(beta): release 3.53.0-beta.0 2026-05-19 05:27:14 +00:00
Paul Nothaft e8c2212dad refactor(slug): extract shared slugify util + scope adminPhotos category lookup (#525)
Folds all three follow-up items tracked in #525 into one commit:

1. Mirror PR #500's category scoping on adminPhotos.js. The admin
   upload route at adminPhotos.js:231 still accepted any category_id
   without event scoping — quietly less strict than the public v1
   API after #500 landed. Same one-liner fix (event_id OR is_global)
   with a matching 400 response shape so admin + v1 stay consistent.

2. Extract a shared slugify() in backend/src/utils/slug.js with the
   NFD-strip-combining-marks fix from #502, and route 5 callers
   through it:
     - adminEvents.js (event-name slug)
     - events.js      (event-create slug)
     - v1/events.js   (replaces local slugify helper)
     - adminArchives.js (archive→category slug)
   For pure-ASCII input the output is byte-identical to each old
   inline pipeline, so existing slugs in the DB keep round-tripping
   cleanly via lookup. Accented inputs now transliterate (Família
   → familia) instead of dropping the diacritic (Família → f-mlia).
   adminCategories.js stays with its own pipeline (underscores-as-
   word-chars semantics differ from the events-style transform —
   changing would silently shift wedding_party → wedding-party on
   new inserts). xmpGenerator.sanitizeKeyword stays unchanged for
   the same compat-cautious reason.

3. Cover the v1 upload happy path. Existing test only exercised the
   400-out-of-scope branch. Add two happy-path cases that stub
   sharp / generateThumbnail / storage.putFromFile and pin the
   response shape (id, category_id, type, etc.) plus the collage-
   slug → type='collage' flip. Temp file recreated in beforeEach
   because the handler unlinks it on success.

Tests:
- New slug.test.js: 22 cases pinning ASCII parity with the legacy
  pipeline (so the refactor is provably non-breaking for existing
  data) and the corrected accent handling across de/es/fr/nl/pt
  inputs, plus CJK and edge-case behaviour.
- events.category.test.js: 4 tests total (2 existing + 2 new happy
  path).
- galleryOgService.shareImage.test.js: 11 (3 added in #521 + 8 pre-
  existing) still pass.

37 tests pass across the three touched files.

Refs: #525, follows up #500 and #502
2026-05-18 23:50:35 +02:00
Paul Nothaft b960639035 fix(og): brandable static title + wider crawler UA coverage (#521)
@Rekoo-PS reported that gallery URLs sent via the WhatsApp Business
API render an unbranded "PicPeak - Photo Sharing Platform" preview
even though manual link sends from the WhatsApp app pick up the
per-event rich preview correctly. Two root causes, two fixes:

1. WhatsApp Business and 3rd-party preview services (Twilio,
   LinkPreview.net, etc.) don't always crawl with the recognisable
   "WhatsApp/X.Y.Z" UA we matched in nginx + galleryOgService.
   Extend the regex (both copies) to also catch WhatsAppBot, wa-bot,
   LinkPreview, and Slack-ImgProxy.

2. Even with broader UA coverage, some senders cache metadata with
   no UA at all and fetch the static SPA shell. That shell's
   <title> was hard-coded to "PicPeak - Photo Sharing Platform" —
   embarrassingly generic for any self-hosted brand. Switch to
   Vite's %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION% HTML
   substitution so self-hosters can bake their brand into the
   fallback at build time. Defaults stay "PicPeak" so the upstream
   image doesn't change behaviour for anyone.

The per-event rich preview path (handleGalleryOgRequest, fired on
matched crawler UAs) is unchanged — this only improves the fallback
for unrecognised UAs and for the SPA-shell title that humans see in
their browser tab.

Adds a vite.config plugin to provide the defaults when env vars
aren't set, so unsubstituted "%VITE_..." literals never reach the
built HTML. Adds .env.example entries explaining the override.

Tests: extend galleryOgService.shareImage.test.js with an
isSocialCrawler suite that pins every documented UA (incl. the new
ones) plus three browser UAs (negative) and null/empty edge cases.
Verified locally: `vite build` with VITE_DEFAULT_TITLE="MyBrand"
produces <title>MyBrand</title> + og:title="MyBrand"; without the
env var falls back to "PicPeak".

Refs: #521
2026-05-18 22:45:00 +02:00