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.
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.
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.
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.
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.
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).
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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
Contributed by @AloePacci on issue #510. Drops their es.json into the
existing locale set, registers Spanish in the language selector with a
flag SVG matching the inline style of the other six locales, and
extends the email pipeline so es-language guests receive a localised
email subject/body where available.
Coverage:
- frontend/src/i18n/locales/es.json — 2132 translated keys. ~824
EN keys are not yet covered; i18next's `fallbackLng: 'en'` handles
those at runtime so the UI never renders a missing key. fr/nl/pt/ru
have a similar (smaller) gap and ship the same way.
- LanguageSelector.tsx — added the ESFlag inline SVG (red/yellow/red
horizontal bands, official #AA151B + #F1BF00; no coat of arms to
stay consistent with the other simple flag components) and a new
entry in SUPPORTED_LANGUAGES.
- emailProcessor.js — added .es to the domain-language heuristic, and
an `es:` row to the three inline-translated snippets
(passwordSecurityI18n / noPasswordI18n / passwordSetAtCreationI18n).
- 106_seed_es_email_template_translations.js (new) — idempotent
seeder for the four customer-facing templates AloePacci translated:
gallery_created, expiration_warning, gallery_expired, archive_complete.
Mirrors the pattern from 099. Template keys without an `es` row fall
back to `en` via the existing resolution chain in
emailProcessor.processTemplate — no functional gap, just untranslated
copy until someone fills them in.
What I deliberately did NOT take from the contribution: the proposed
in-place edit of migration 075 (history mutation — won't reseed for
existing installs anyway) and the whitespace/`gallery_list_html`-drop
churn in emailProcessor.js (would have regressed the #354 follow-up).
The semantic additions from those files are preserved via 106 and the
targeted edits above.
Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.
Re-applying exactly the same change set:
- `backend/migrations/core/072_add_max_upload_batch_size.js`
recreated, with a comment pointing at the regression in case the
same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
the setting from query cache and falls back to 95MB (Cloudflare-safe
headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
added the field to the state type + defaults + load path + the
Site-Configuration input.
Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
filename, so the runner skips re-applying. The setting row in
`app_settings` is also untouched (the deletion was source-only, no
down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
072 normally and seeds the setting at 95.
Two latent install-time issues that emitted scary postgres ERROR lines
on every fresh start but didn't actually break anything. MrGabri flagged
them after #494 had already cleared the FK-ordering crash.
1. Migration 035 builds three `CREATE INDEX` statements against
`backup_runs(created_at, …)`, but 029 creates the table with
`started_at` and no `created_at`. The wrapping try/catch silently
swallowed the resulting `column "created_at" does not exist` ERROR,
so the migration "succeeded" without ever creating the indexes.
Switched 035 to reference `started_at` (same chronological semantics)
and added migration 105 to create the same indexes idempotently for
deployments whose 035 already ran and silently failed.
2. `run-migrations-safe.js` snapshots `appliedFilenames` *before*
`detectExistingSchema()` runs. When `detectExistingSchema()` inserts a
row for e.g. `004_add_categories_and_cms.js` (because its tables exist
from a partially-completed prior install), the subsequent migration
loop still doesn't know about that insert, attempts the legacy
migration anyway, and its transaction-internal
`insert into migrations` conflicts with the row already there.
Re-query the applied set after detectExistingSchema so the loop sees
the corrected snapshot.
No behavioural change for healthy installs. New installs no longer log
the `column "created_at" does not exist` or `duplicate key value
violates unique constraint "migrations_filename_unique"` ERRORs.
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.
Backend:
- imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
- migration 104: photos.preview_path + lightbox_preview_enabled setting
(off by default, JSON-stringified for SQLite/Postgres parity)
- GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
ETag based on mtime+photoId+watermarkHash
- preview_url surfaced in the photo response only when the toggle is on
- admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
skipping videos
- backup walk + archive cleanup + photo-delete now include previews/
Frontend:
- PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
- ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
Regenerate All Previews button (gated until the toggle is on)
- en/de locale strings; nl/pt/ru/fr fall back to en
Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
The gallery promotional banner (#440) read as visually offset from
the gallery footer because:
- Footer used `container text-center px-4` (full container width,
centered text).
- Promo block used `container py-4 sm:py-6` with an inner
`max-w-3xl mx-auto` wrapper holding left-aligned text — a
narrower column with left-aligned content sitting in the
middle of the page.
Two issues compounded: the column was narrower than the footer AND
its text alignment differed. Reported by Rekoo-PS in #482 with a
screenshot showing the misalignment, with a request for an admin
alignment option.
Fix:
- Drop the inner max-w-3xl wrapper. Promo content now spans the
same .container width as the footer, eliminating the
narrower-column visual.
- Default text alignment changed from left → center to match the
footer.
- New `branding_promo_alignment` setting ('left' | 'center' | 'right',
default 'center'). Surfaced as a dropdown next to the existing
Position dropdown on the BrandingPage. Live preview block on the
BrandingPage mirrors the gallery render so admins see what
guests will see.
- Also replaced the no-op `prose-sm` prose-modifier with a real
`prose prose-sm` outer class so the existing `prose-a:text-accent`
modifier actually takes effect (it didn't before — modifiers
without an outer .prose are silently ignored by Tailwind
Typography).
Migration 103 seeds the new setting at 'center' so existing
installs that have a promo banner today see the corrected
alignment immediately on next deploy.
i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and
flagged for native review per project convention.
Background: galleryOgService already serves OG/Twitter Card meta tags
to social-crawler User-Agents (WhatsApp, Facebook, Slack, Telegram,
Discord, ~21 in total) for /gallery/:slug URLs. Today the og:image
is always the brand logo with the inline rationale "no protected
photo content".
#474 asked for a hero/cover photo preview. The trade-off is that any
URL embedded in og:image is fetched unauthenticated by every
link-preview crawler — so an opted-in image is effectively public
to anyone the gallery URL is shared to. Ship as a per-event boolean,
default FALSE, so existing galleries never start surfacing photos
without explicit admin intent.
Schema (migration 102):
- events.og_image_share_enabled BOOLEAN NOT NULL DEFAULT FALSE.
Backend:
- galleryOgService.buildOgMetadata: when opt-in is on AND a
hero_photo_id is set AND the photo has a generated thumbnail,
emit og:image as /og/gallery/:slug/cover. Falls back to the
brand logo on any miss (deleted hero, missing thumbnail, no
opt-in) so a half-configured gallery still gets a polished
preview rather than a broken-image src.
- galleryOgService.handleGalleryOgCover: new public endpoint that
streams the hero thumbnail. Validates slug shape, checks the
opt-in flag + hero presence + thumbnail existence; returns 404
on any failure. ETag = thumbnail mtime + photo id so a
regenerated thumb busts crawler caches. Cache-Control:
public, max-age=300 (short — admins shouldn't wait an hour for
a cover swap to land in chat previews).
- server.js: mount the new GET /og/gallery/:slug/cover route. The
existing nginx ^~ /og/gallery/ proxy block already covers it.
- adminEvents.js: validator + persistence on POST + PUT.
formatBoolean coercion so SQLite (0/1) and Postgres (boolean)
both behave correctly.
Frontend:
- Event type + UpdateEventData carry og_image_share_enabled.
- EventDetailsPage adds a checkbox under the HeroPhotoSelector,
disabled when no hero photo is picked. Help text deliberately
spells out the public-by-design consequence — admins shouldn't
flip this on for a sensitive gallery without realising what
they're sharing with link-preview crawlers.
Tests: 8 new in galleryOgService.shareImage.test.js — pin the
cover-vs-logo decision contract (3 cases) plus the defensive
fallbacks (deleted hero, missing thumbnail) and the 404 contract
on the cover endpoint (4 cases). The 404 tests assert that
ensureThumbnail() is NOT called when opt-in is off, so a future
refactor can't accidentally widen the unauthenticated cover
endpoint to expose a hero the admin hasn't shared.
i18n: en + de hand-translated; nl + pt + ru + fr machine-translated
and flagged for native review per project convention.
Post-merge cleanups after #403 (customer portal):
- Renumber 090_backfill_photo_dimensions_v2.js → 096 to follow #403's
090_add_customer_accounts ... 095_add_customer_portal_flag chain.
- customerAccountsService.js: TODO note on must_change_password
documenting that the column is decorative until an admin
pre-loaded-password flow ships (mirrors what adminAuth does for
must_change_password today).
- customerAuth.js: doc-comment on the /login route explaining why the
customerPortal feature flag deliberately doesn't gate it (toggle off
hides UI, doesn't revoke existing-customer access; deactivate
individual accounts to lock out).
- 095_add_customer_portal_flag.js: header comment said "Migration 094"
(copy-paste from 094) — now matches the filename.
The aspect-aware gallery layouts (masonry / mosaic / justified) read
photo.width and photo.height to size each card to the source's real
proportions. Two import paths were inserting rows without those
fields, which forced MasonryGalleryLayout to fall back to a hard-coded
800×600 default — every card came out the same shape, so users
reported masonry as "always cropped to 1:1ish" no matter which
thumbnail fit mode they chose.
- fileWatcher.js: extract dims with sharp.metadata() before insert.
- s3AutoImporter.js: same, materialising a tmp local copy via
withLocalCopy so it works in S3 mode.
- migration 090: backfill any pre-existing rows with NULL dims
(skips videos, skips S3 deployments — those need the writer fix
alone since migrations cannot reach the storage backend).
- imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to
'inside' (only kicks in when the seed setting is missing — existing
installs keep their saved value). Add UI tooltip recommending
'inside' for masonry/mosaic/justified, 'cover' for uniform grids.
i18n covers all six locales.
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.
* New `customerPortal` feature flag (foundation flag for the
not-yet-built calendar/quotes/bills/messaging customer
surfaces). Defaults FALSE on fresh installs, TRUE on existing
installs (events > 0) via migration 095 so live customer
accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
event_customer_assignments, customer_password_resets, plus
RBAC permissions customers.view / .create / .delete granted
to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
deactivate, reset password) + /api/customer/auth/* +
/api/customer/* (login, dashboard, accept-invite, reset).
Customer JWT bypass minted via
/api/customer/events/:slug/access-token so existing gallery
middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
customerPortal, with login / dashboard / accept-invite /
reset pages and a customer-side sidebar layout.
/admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
Customer portal card. The maintainer's Features tab stays the
single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
when the flag is off; backend ignores customer_account_ids in
that case instead of erroring the whole event save.
Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Combined footer overhaul:
- Per-CMS-page show_in_footer toggle (#441) — admins can hide
Impressum / Datenschutz from the gallery footer when an external
privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
global default authored as markdown in branding settings, plus a
three-way per-event override on the Edit Event form
(inherit / custom / off). Backend nulls promo_markdown automatically
when mode != 'custom' so stale text never persists.
Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.
i18n covers all six locales (en/de/nl/pt/ru/fr).
Targets the beta branch.
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.
Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.
Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
Migration 088 detects existing-vs-fresh installs from the events
table:
* Existing install (events>0) → all 9 flags TRUE so nothing
vanishes from an admin's UI on upgrade.
* Fresh install (events=0) → spec defaults: galleries,
reminderEmails, analytics, userManagement TRUE; calendar,
calendarBooking, quotes, bills, messaging FALSE.
- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
`settings.edit`. Server enforces the same dependency rules the
frontend does (galleries always TRUE, quotes=false → bills=false,
calendar=false → calendarBooking=false). PUT writes one
`feature_flags_updated` activity log row with the diff.
Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
AdminLayout so flag fetches carry the auth cookie. Source of truth
is the server response; staged is a local copy that the Features tab
edits and the Save button PUTs.
- `RequireFeature` route guard for /admin/analytics and /admin/users —
redirects to /admin/dashboard when the corresponding flag is OFF.
- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
Branding, Event Types, Backup, CMS Pages (now Settings tabs).
Feature-gated: Analytics, Users.
- Old top-level routes (/admin/email, /admin/branding, /admin/event-
types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
/admin/settings?tab=<key> so existing bookmarks don't 404.
- SettingsPage rewritten with a 6-group inner-nav (General /
Content & Appearance / Communication / Privacy & Security /
Integrations / System) and 19 tabs. New Features tab is the
default landing tab. URL ?tab=<key> roundtrips with state — deep
links and the back button work.
- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
Analytics + User Management (the two flags that gate sidebar items
in this PR). All other toggles disabled with a "Not yet available"
lockedReason — the cards still render so admins see the roadmap, but
the flag has no UI effect until the surface ships in its own PR. The
galleries card is locked TRUE per spec (foundation, can't be off).
- Live SidebarPreview reflects unsaved staged changes — admins see
what their sidebar will look like before they save.
- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
Features tab copy, the new Settings group labels, and the lifted
tab titles.
Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
rule enforced (bills forced false when quotes=false even when bills=
true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
enabled, toggling Analytics off + saving updates the sidebar +
redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
The "Send Test Email" button on the Update Notifications settings page
called sendUpdateNotificationNow() — which bailed out with "No updates
available" when the instance was already on the latest version. Admins
on a current install had no way to verify their SMTP / recipient list
was working until an update happened to be pending. Reported in #418
by @Rekoo-PS.
Changes:
- Add migration 087: insert a dedicated `version_update_test` email
template (EN + DE, matching the existing version_update_available
convention) with copy that reads as a config-check rather than as a
real update notice. Subject prefixed with [TEST] so it's unambiguous
in the inbox. Variables: current_version, channel, recipient_email.
- Replace sendUpdateNotificationNow() with sendTestUpdateNotification()
in updateNotificationService.js. The new path:
- Always sends — no updateAvailable bail-out.
- Uses the version_update_test template.
- Falls back gracefully if checkForUpdates fails (so a transient
GitHub API hiccup doesn't block a config-check email).
- Does NOT update last_notified_version — that field stays owned by
the real-update path so a test send doesn't shadow a future
genuine notification for the same version.
- Wire /admin/system/updates/notifications/send to the renamed function.
No frontend change needed (the button already calls this endpoint).
Verified locally with the dev mailhog: clicking Send Test Email on a
3.42.3-beta.0 instance (which has no pending update) delivers 4 emails
to all admin recipients with subject "[TEST] PicPeak Update Notification
— configuration check" and body interpolated correctly. Returns
{success: true, successCount: 4, ...} — previously would have returned
{success: false, message: "No updates available"}.
Move thumbnail / EXIF / dimensions / watermark / webhook work off the
upload request thread and into a background worker pool. Upload
requests now return 202 in seconds even on NFS-backed storage; the
worker(s) drain the pending queue independently and update each
photo's processing_status to 'complete' or 'failed' on its own.
Schema (migration 085_async_photo_processing.js):
- photos.processing_status enum default 'complete' (existing
rows are already done)
- photos.processing_error populated on 'failed'
- photos.processing_started_at timestamp for janitor recovery
- photos.upload_id groups all photos from one upload
request so the frontend can poll
status by group
- indexes on processing_status and upload_id for queue lookups
services/photoProcessor.js
- queueFilesForProcessing(files, options) — shared helper used by
the admin and gallery upload routes. Moves files to final storage
+ inserts pending rows; returns { uploadId, photos, errors }.
- processPhoto(photoId) — worker-mode: reads original from storage
via withLocalCopy (transparent local/S3), generates thumbnail and
EXIF/dimensions or video metadata, queues watermark, fires
photo.uploaded webhook, marks 'complete'. Throws => caller marks
'failed' with the error message.
- processUploadedPhotos kept untouched — chunkedUploadService still
uses the synchronous path.
services/backgroundProcessor.js (new)
- N independent worker loops per backend instance (default 2,
UPLOAD_PROCESSOR_CONCURRENCY env override).
- Multi-pod safe: postgres SELECT FOR UPDATE SKIP LOCKED, sqlite
UPDATE-with-status-guard. Pods race on rows, exactly one wins.
- Janitor every minute resets photos stuck in 'processing' for >10
minutes (worker died, pod restarted) back to 'pending'.
- UPLOAD_PROCESSOR_DISABLED=true opt-out for CI/test.
- Started from server.js after the other long-running workers.
routes/adminPhotos.js — POST /:eventId/upload
- Replaced batch-of-25 sync processing loop with per-file
move-to-storage + insert-pending. Response is now 202 with
upload_id, count, photo_ids in addition to the legacy
successCount / replacedCount fields the existing frontend reads.
- Per-request temp directory cleanup is now a single idempotent
handler on res.finish/res.close (was three inline blocks for
error paths only, leaking dirs on success — original bug from
contributor analysis).
- GET /uploads/:upload_id/status — JSON snapshot of pending /
processing / complete / failed counts plus per-photo state.
- GET /uploads/:upload_id/stream — SSE upgrade. Polls internally
every 1.5s, emits on snapshot change, ends when all photos
reach a terminal state.
- POST /photos/:photoId/retry — flips a 'failed' photo back to
'pending' so the worker picks it up again.
- GET /:eventId/thumbnail/:photoId now returns 503 with Retry-After
while the photo is still pending/processing, and 422 on 'failed'.
The admin grid renders placeholders accordingly.
routes/gallery.js — POST /:eventId/upload (guest)
- Refactored to use queueFilesForProcessing instead of the synchronous
processUploadedPhotos. Same 202 + upload_id shape.
- GET /:slug/photos now filters processing_status to 'complete' (or
NULL for pre-migration rows) so guests never see in-flight photos.
Side-effect timing change:
- photo.uploaded webhook now fires from the worker after the photo
is actually processed (thumbnail + dimensions populated) instead
of from inside the upload request. Same payload fields. Worth a
one-line note in the changelog.
Two settings with overlapping names but different value sets were being
conflated:
- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position (hero block, vertical): 'top'|'center'|'bottom'
getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.
Fix:
1. Drop the bogus mapping. branding_logo_position is no longer read by
getBrandingDefaults — it doesn't belong there. The fallback default
('top') is used unless the request body explicitly provides
hero_logo_position, which is independently validated.
2. Migration 084_fix_hero_logo_position normalises any existing rows
whose hero_logo_position is outside ('top','center','bottom') back
to 'top'. Without this, affected events would continue to 400 on
every save until the admin manually picks a valid option.
Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
Closes the user-facing surface for the two #328 follow-ups previously
landed in code form (presigned route + S3 mode notes), plus the schema
migration that backs both #328 and #327 follow-ups.
Migration 083
- events.allow_presigned_download — per-event opt-in for the
presigned-URL "Download All" path. Off by default because it bypasses
watermarks; admins flip it knowingly. Mutually exclusive with
watermark_downloads.
- webhooks.filter (jsonb default {}) — dot-path equality predicate
evaluated at fire time. Empty object = no filter, fire always.
Backs the filter logic that shipped with #327.
- webhooks.template (text nullable) — optional ${dot.path} string
substitution applied at delivery time. NULL = use the default JSON
envelope (back-compat). Backs the template logic from #327.
S3 prefix walker (services/s3AutoImporter.js)
- Replaces the chokidar file-watcher in S3 mode (where there's no
inotify equivalent on remote objects).
- Polls every active event's S3 prefix every 5 min by default
(STORAGE_AUTO_IMPORT_INTERVAL_MS overridable).
- Eventual-consistency gate: an object is only imported after it's
been seen for two consecutive polls. Avoids flapping when S3 returns
a freshly-uploaded object that disappears on the next list (a
documented S3 behavior on certain backends).
- Skips generated artifacts (thumb_*, hero_*, dot-files).
- Inserts photos rows + fires photo.uploaded webhooks the same way
the local fileWatcher does.
- Opt-in via STORAGE_AUTO_IMPORT=true. Off by default because it adds
API call cost.
EventDetailsPage UI (frontend)
- Round D queryKey alignment for #325 dedup — replaces useQuery on
publicSettingsService with the shared usePublicSettings() hook so
the page joins the same React Query cache as every other consumer.
- Per-event "Allow direct S3 download (no watermark, S3 mode only)"
toggle in Download Protection. Disabled when watermark_downloads is
on; tooltip explains the bandwidth/watermark trade-off. Toggling
watermark_downloads on automatically clears allow_presigned_download
to keep the two mutually exclusive in the UI.
Verified live against MinIO
- Presigned: GET /api/gallery/.../download-all → 302 with
Location: http://minio:9000/...?X-Amz-Signature=...&X-Amz-Expires=300.
Following the URL inside the docker network → HTTP 200, valid
PK ZIP archive containing the photo.
- Auto-importer: dropped a file via `mc cp` directly into the bucket;
watcher imported it after 2 polls; webhook subscribed to
photo.uploaded fired with source=s3-auto-import; receiver got POST
with valid HMAC, status=success, 3ms latency.
PicPeak POSTs lifecycle notifications to admin-configured URLs. Each
delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header.
Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration
tests, full UI click-through via Chrome DevTools.
Schema (migration 082)
- webhooks: id, name, url, secret (plaintext — required to compute HMAC
for every outbound POST), secret_preview, events[], active, filter,
template, created_by, timestamps, last_success_at/last_failure_at.
- webhook_deliveries: webhook_id (FK CASCADE), event_type, payload,
attempt_count, status (pending|success|failed), response_status,
response_body (truncated to 1KB), latency_ms, next_retry_at,
last_error, created_at, completed_at. Composite index
(status, next_retry_at) serves the worker's hot-path query.
Service + worker
- webhookService.fire(eventType, data) — non-throwing entry point used
by lifecycle hooks. Looks up active webhooks subscribed to the event
and applies their per-webhook filter (dot-path equality predicate)
before enqueueing one webhook_deliveries row per match. Filter and
template logic ship in this commit; admin surfaces in the follow-up.
- webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5
pending rows; per delivery: re-validates URL via networkValidation
(DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS),
signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome.
Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response
body truncated to 1KB before storage. If a webhook has a template,
the rendered string replaces the JSON envelope as the request body
(signature is computed over the bytes actually sent).
Lifecycle wiring
- adminEvents.js POST /events → event.created (+ event.published when
not draft); POST /:id/publish → event.published.
- routes/events.js (legacy public POST) → event.created + event.published.
- routes/v1/events.js (#322 API) → event.created + event.published on
create, photo.uploaded on photo POST.
- archiveService.archiveEvent() → event.archived. Per-photo
photo.deleted intentionally NOT fired during cascade — receivers
infer from event.archived to avoid flooding (issue spec).
- expirationChecker.handleExpiredEvent() → event.expired BEFORE the
cascading archive (so receivers see expired→archived in order).
- adminPhotos.js — photo.uploaded on each batch row, photo.deleted on
single + bulk delete.
- photoProcessor.js — photo.uploaded for guest uploads + auto-import
(covers all entry paths).
- fileWatcher.js — photo.uploaded on add, photo.deleted on unlink
(local mode only).
Admin endpoints (mirrors adminApiTokens.js pattern)
- /api/admin/webhooks: GET list, POST create (returns plaintext secret
exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic
fire), GET :id/deliveries (paginated, filter by status), GET
:id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay.
Frontend
- Settings → Webhooks tab (mirrors API Tokens layout): name + URL +
event checkboxes + "Advanced" expander for filter (JSON) and template.
Plaintext secret shown once on creation with a Copy button. Active/
Disabled toggle button per row.
- /admin/webhooks/:id/deliveries — operational debug surface. Table
with timestamp/event/status/attempts/HTTP/latency. Status filter chips
(all/pending/success/failed). Row click → slide-over with payload +
signature + response body. Replay button on failed rows. Send-test-event
dialog. Auto-refresh every 10s.
Dev infrastructure
- dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that
records every POST to an in-memory ring buffer. Exposes GET /requests
for the E2E spec to assert deliveries landed with the right HMAC.
Sibling pattern to MinIO. Reachable from the backend at
http://webhook-receiver:8888 inside the picpeak network.
Tests
- backend/__tests__/integration/webhookDelivery.test.js (8/8) —
signature verification, headers, retry/backoff, max-attempts → failed,
response truncation, disabled-mid-flight, SSRF block, start/stop
idempotency.
- tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger
event.published → assert receiver got POST with valid HMAC → visit
deliveries page → row visible with status=success → API test event →
API replay → disable webhook → assert no new delivery.
Docs
- README §"Webhooks" — event catalog, payload shape, HMAC verification
in Node + Python + bash, retry semantics, SSRF protection.
- .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS,
WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS,
WEBHOOK_MAX_ATTEMPTS.
Out of scope for v1 (per issue): webhook templates' code-eval (the
${dot.path} substitution that ships is pure string replacement, no
expression engine — see follow-up commit), per-webhook rate limiting
beyond the global concurrency cap, synchronous "ask before delete"
webhooks.
Spanning files
- App.tsx pulls in this commit with both the AnalyticsBootstrap
(#325 dedup) and the WebhookDeliveriesPage route registration.
Splitting via git add -p was forfeit for sanity; the single 92-line
diff is honest about both contributions.
- adminEvents.js diff bundles the webhook fires AND the
allow_presigned_download field plumbing (#328 follow-up). Same
reasoning.
- The new webhookService/Worker/adminWebhooks files include the filter
and template logic from the follow-up — they were authored in one
pass; splitting them post-hoc would have produced fragile partial
files. The follow-up commit covers the migration and the UI for these.