Compare commits

..

1168 Commits

Author SHA1 Message Date
Paul Nothaft fd61416665 Merge pull request #578 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.57.0-beta.0
2026-05-29 16:26:46 +02:00
github-actions[bot] 463ef4d0fd chore(beta): release 3.57.0-beta.0 2026-05-29 14:25:27 +00:00
Paul Nothaft e537923857 Merge pull request #576 from the-luap/docs/release-cadence-565
docs(release): establish stable-channel cadence + promotion process
2026-05-29 16:24:59 +02:00
Paul Nothaft 48cf1121e5 Merge pull request #575 from the-luap/feat/clickable-version-links-566
feat(admin): clickable version links + update-available modal with changelog & upgrade command
2026-05-29 11:35:06 +02:00
Paul Nothaft 43fe0eb70d Merge pull request #577 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.56.0-beta.0
2026-05-29 11:34:48 +02:00
github-actions[bot] 84486c65f1 chore(beta): release 3.56.0-beta.0 2026-05-29 09:27:47 +00:00
Paul Nothaft 5f0fcc225c Merge pull request #555 from Luca-Timo/feat/crm-pr
feat: CRM module — quotes, contracts, invoices, hours, calendar, tax
2026-05-29 11:27:23 +02:00
Paul Nothaft 832f7bad45 feat(admin): update-available modal with aggregated changelog + upgrade command (#567)
Closes #567.

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

## Backend

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

## Frontend

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

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

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

This adds:

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

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

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

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

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

Revert the DE translation to its original wording, then build a
{ at, minutes } object at the call site so EN ("until {{at}}") and
DE ("innerhalb von {{minutes}} Minuten") each pick up the variable
they need. `minutes` rounds UP so a 14m 32s remainder displays as
"15 Minuten" rather than promising 14 the customer can't actually
hit.
2026-05-27 16:06:35 +02:00
Paul Nothaft 3ceeccd85a Merge pull request #562 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.55.0-beta.0
2026-05-27 16:01:01 +02:00
github-actions[bot] fd8ee5d52c chore(beta): release 3.55.0-beta.0 2026-05-27 13:53:11 +00:00
Paul Nothaft e016f510b6 Merge pull request #561 from the-luap/fix/android-download-latency-554
fix(lightbox+events): Android download lag, multi-photo Web Share re-land, theme branding inheritance
2026-05-27 15:52:44 +02:00
Paul Nothaft d5a37df2c4 fix(events): preserve branding inheritance when saving events with null color_theme
API-created events (and any event whose `color_theme` is NULL) had two
visible bugs in the admin edit page (#550 follow-up — PR #552 fixed the
v1 POST write path, this fixes the read/save path):

1. The theme picker initialised to the hardcoded `GALLERY_THEME_PRESETS
   .default.config` ("Classic Grid", green) — which had nothing to do
   with the admin's actual branding palette, while the gallery itself
   was rendering with the branding theme. Confusing visual mismatch.
2. Saving the event for ANY reason (changing the date, password, etc.)
   wrote `color_theme = 'default'` back to the row because the save
   handler always emitted the picker's initial preset name. That
   silently replaced "inherit from branding" with the literal Classic
   Grid preset, so the gallery's visuals jumped.

Two fixes, both in EventDetailsPage:

- Add a `themeChanged` flag, defaulted false. Flip in the picker's
  onChange / onPresetChange / onSyncFromBranding callbacks. The save
  handler now only writes `updateData.color_theme` when the flag is
  true, so saving without touching the picker preserves NULL.
- When `event.color_theme` is null and `publicSettings.theme_config`
  (the site branding) is available, initialise `currentTheme` from
  branding instead of the Classic Grid preset, with currentPresetName
  set to 'custom' (since inherited branding isn't a named preset).
  Falls back to the Classic Grid preset only when no branding theme
  exists either.

Combined effect: opening an API-created event shows the same palette
the gallery uses, and saving without changing the theme preserves the
inheritance. Existing events with a stored color_theme are unaffected
(themeChanged stays false → no write, just like before for the
common no-change-to-theme save).
2026-05-27 15:44:41 +02:00
Luca 45f0606ea3 fix(i18n): align quoteResponse.changeWithin DE placeholder with call site
The German string used `{{minutes}}` while the call site at
QuoteResponsePage.tsx:300 passes `{ at: <localized time> }`, matching
the English string's `{{at}}`. Result on the public quote page when
the customer had already responded: the literal text "{{minutes}}"
rendered instead of the unlock time.

Switched the German wording to match the English semantics
("until X:XX") since the underlying value is an absolute time, not a
minutes-remaining count — the previous DE wording was also wrong about
WHAT the variable meant.
2026-05-27 15:38:35 +02:00
Paul Nothaft d5823c79d9 feat(lightbox): multi-photo Web Share save-to-Photos on iOS (#557)
Extends #531 to the selection-based bulk-download flow. On iOS with a
selection at or under MAX_WEB_SHARE_FILES (25), galleryService
.downloadSelectedPhotos now routes through navigator.share({ files })
so the photos land directly in Photos via the share sheet's "Save N
Images" action. Above the cap, anywhere off-iOS, or on any failure,
the existing server-side zip path runs unchanged.

The 25-file cap is the empirically-safe ceiling: iOS Safari's share
sheet starts choking beyond ~25–30 files, and every File materialises
as an in-memory Blob before share() is invoked, so a 500-photo
selection would buffer multiple GB on the device.

trySaveMultipleToDevice exposes three outcomes:
- 'shared'    — share() resolved; flow ends
- 'dismissed' — user cancelled (AbortError); flow ends without zip
                fallback so dismissal isn't silently overridden
- 'fallback'  — capability missing or unexpected failure; caller
                takes the zip path

Partial shares are deliberately avoided: a single failed photo fetch
collapses the whole selection back to the zip endpoint rather than
sharing only the photos that resolved.

All 4 grid callers (PhotoGrid, PhotoGridWithLayouts, GalleryStoryLayout,
GalleryPremiumLayout) funnel through downloadSelectedPhotos, so no
caller-side changes are needed. Android, desktop, Firefox, and
"Download All" are untouched.

Layers on top of #556 (iOS-only gating via isIOS()). Builds against the
fix/android-download-web-share-554 branch.
2026-05-27 15:37:52 +02:00
Luca 83933baeec fix(crm): self-heal missing CRM email templates at boot + recover queue
The CRM template seeders (crmEmailTemplates / contractEmailTemplates /
eventReminderTemplates) were idempotent and ready, but only
contractEmailTemplates was actually called (lazily, by contractService
sends). crmEmailTemplates had no caller anywhere — every install that
didn't pre-exist its templates failed every quote_sent / invoice_sent /
storno_issued / invoice_reminder_* send with "Email template '<key>'
not found". The queue processor retries 3 times then leaves the row
in status='pending', retry_count=3, silently dead with no admin
surface (see project_crm_backlog for the eventual System Health page).

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

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

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

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

A new resolveBillingRecipients helper centralises the rules:
prefer billing_email, dedupe addresses case-insensitively, keep
per-doc cc_pdf_email as a supplemental CC. Lives in its own file
(_billingRecipients.js) to match the _renderContext.js convention.
2026-05-27 14:04:15 +02:00
Paul Nothaft 04795219a0 fix(lightbox): eliminate download lag on Android by skipping the blob round-trip
`savePhotoToDevice` previously buffered the full image through JS as a
Blob on every platform before clicking <a download>. On cellular this
added ~5s of dead air between the button press and the browser's
download dialog, prompting users to re-click and produce duplicate
downloads (#554 follow-up, post-#556).

The blob round-trip is only required for the iOS Web Share path
(`navigator.share({files})` needs File objects in hand). On Android and
desktop the browser can fetch the download URL itself and show its own
progress in the notification shade — instantly. So iOS keeps the
existing flow; everywhere else gets a direct anchor navigation.

The new `triggerDirectDownload` helper uses `api.getUri()` so the path
also works in split-origin deployments (where the existing hardcoded
`/api/...` pattern used by `downloadAllPhotos` would 404).

Tests updated: Android / desktop / regular-Mac branches now assert that
`fetchPhotoBlob` is NOT called and `triggerDirectDownload` is invoked
with a `/gallery/{slug}/download/{id}` URL. iOS tests unchanged.
2026-05-27 13:16:36 +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
Paul Nothaft 9ae1f79769 Merge pull request #559 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.7-beta.0
2026-05-26 22:54:48 +02:00
github-actions[bot] 54b185db47 chore(beta): release 3.54.7-beta.0 2026-05-26 20:54:29 +00:00
Paul Nothaft 578397bc6b Merge pull request #556 from the-luap/fix/android-download-web-share-554
fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
2026-05-26 22:54:06 +02:00
Paul Nothaft 2a309c75a7 fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
PR #531 routed the single-photo download through navigator.share()
whenever canShare({files}) returned true, on the assumption that any
mobile share sheet would expose a "Save Image" action. That holds on
iOS — Safari's share sheet has a first-party "Save to Photos" entry —
but on Android the system share sheet only lists installed apps that
registered an image/* intent (WhatsApp, Telegram, Drive, etc.). There
is no built-in save-to-Gallery action, so Android users tapping the
download button got an app-picker instead of the file saved to their
device.

Fix: gate the Web Share branch behind a UA-based isIOS() check. Android,
desktop, and everything else fall through to the existing <a download>
path (file lands in Downloads, visible in the Photos / Gallery app
afterwards — same behaviour as before #531). iOS — including iPadOS
13+, which reports as MacIntel + touch — keeps the share-sheet flow
that drops directly into Photos.

UA-sniff is the only available signal here: canShare({files}) is true
on both iOS Safari and Chrome Android, so feature detection cannot
distinguish them.

Tests pin all six scenarios — iOS share path, Android download fallback
(even with canShare=true), desktop, iPadOS-as-Mac detected as iOS, regular
Mac NOT detected as iOS, AbortError dismissal preserved (no surprise
fallback), and non-Abort share() rejection falls back to download.
2026-05-26 22:37:01 +02:00
Luca 6d302e7998 feat(crm): add event_reminder_* templates to dev email tester
The pre-event reminder feature shipped with 5 seeded templates
(event_reminder_default + wedding/birthday/corporate/other) but the
CRM → Development "Send any CRM email to me" picker only listed the
quote/invoice/contract templates. Maintainer can now eyeball each
reminder category's body without staging a real event.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Smoke-tested on fresh sqlite DB: 84 migrations apply cleanly,
all CRM tables present, seeds populated.
2026-05-26 18:18:15 +02:00
Paul Nothaft b5e7f9cec1 Merge pull request #553 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.6-beta.0
2026-05-26 11:29:23 +02:00
github-actions[bot] 08fa2e9b63 chore(beta): release 3.54.6-beta.0 2026-05-25 20:19:09 +00:00
Paul Nothaft 7ef0e40e7c Merge pull request #552 from the-luap/fix/v1-events-feedback-theme-550
fix(api/v1): accept color_theme + create feedback row on event create (#550)
2026-05-25 22:18:49 +02: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
Paul Nothaft ce1ccf0a4d Merge pull request #549 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.5-beta.0
2026-05-22 14:43:15 +02:00
github-actions[bot] 793aa1247e chore(beta): release 3.54.5-beta.0 2026-05-22 12:41:21 +00:00
Paul Nothaft b351d17ee9 Merge pull request #548 from the-luap/fix/nginx-forwarded-proto-547
fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
2026-05-22 14:40:55 +02:00
Paul Nothaft 5488de3383 fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
When PicPeak runs behind NPM / Traefik / Caddy, the inner nginx receives
plain HTTP from the outer proxy. The previous `X-Forwarded-Proto $scheme`
therefore always forwarded "http" to the backend, even when the public URL
was HTTPS. Express has `trust proxy` enabled for loopback/linklocal, so
req.secure became false, the Secure cookie flag wasn't set, and generated
URLs (cookies, tokens) used http://.

Add a top-of-file `map` block that picks the incoming X-Forwarded-Proto
when present and falls back to `$scheme` for direct access. Applied to both
nginx.conf (bundled production image) and nginx.dev.conf.

Validated with `nginx -t` against nginx:1.28-alpine (the same image used
by Dockerfile.prod / Dockerfile).
2026-05-22 13:32:01 +02:00
Paul Nothaft 6b6ac64346 Merge pull request #537 from rpintodasilva/imp/french-translation
French Transalation - v2
2026-05-22 10:30:44 +02:00
Paul Nothaft 27e9b0535e Merge pull request #545 from the-luap/chore/clawpatch-review-fixes
chore: address clawpatch review findings
2026-05-21 19:07:17 +02:00
Paul Nothaft dba98f1325 chore: address clawpatch review findings (test scope, deps, legal-page hardening)
- frontend: `npm test` now runs all 7 vitest suites instead of one hardcoded
  file; the previously skipped ProtectedImage / Skeleton / usePublicSettings /
  contrast / themeMigration / url suites are now active in CI
- frontend: wrap ThemeCustomizerEnhanced test in QueryClientProvider so the
  newly-enabled run passes (component uses useQuery internally)
- root: drop unused better-sqlite3 / canvas / node-fetch + their
  prebuild-install/tar-fs override (backend keeps its own copies); add dotenv
  so playwright.config.ts can load on a clean install; add name/version/private
- LegalPage: scheme-validate external_url before window.location.replace so a
  CMS edit can't redirect visitors to javascript:/data:
- LegalPage: force rel="noopener noreferrer" on target="_blank" anchors in
  sanitized CMS HTML to block reverse-tabnabbing
2026-05-21 17:10:34 +02:00
Paul Nothaft 1cf492f4b9 Merge pull request #544 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.4-beta.0
2026-05-21 10:24:50 +02:00
github-actions[bot] 955ada4945 chore(beta): release 3.54.4-beta.0 2026-05-21 08:24:22 +00:00
Paul Nothaft 9607b4666c Merge pull request #542 from the-luap/fix/recover-orphaned-527
fix: recover three orphaned commits from #527 (BRAND_TITLE runtime, Web Share, pan zoom)
2026-05-21 10:23:59 +02:00
Paul Nothaft 8c36f80471 Merge pull request #543 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.3-beta.0
2026-05-21 10:22:59 +02:00
github-actions[bot] c1e8e0d73a chore(beta): release 3.54.3-beta.0 2026-05-21 08:22:26 +00:00
Paul Nothaft 3e39112a12 Merge pull request #541 from the-luap/fix/lightbox-heart-icon-fill-538
fix(lightbox): fill the heart icon when liked (#538 follow-up)
2026-05-21 10:21:59 +02:00
Paul Nothaft efa6b4a205 fix(brand-title): runtime substitution so GHCR-image users can override (#521 follow-up)
@Rekoo-PS confirmed the prior #521 fix landed on beta but reported the
preview still shows the default "PicPeak" title — their brand is
"arkan-studio". Root cause: that fix used Vite's build-time
%VITE_DEFAULT_TITLE% substitution. Self-hosters running the pre-built
ghcr.io/the-luap/picpeak/frontend image can't override at build time
without rebuilding, so they were stuck with whatever the upstream
build baked in.

Pivot to runtime substitution: the frontend container now reads
BRAND_TITLE / BRAND_DESCRIPTION env vars on startup and envsubsts
them into index.html. Change the values in .env, restart the frontend
service, done — no rebuild required.

Mechanics:
  - frontend/index.html: tokens are now ${BRAND_TITLE} /
    ${BRAND_DESCRIPTION} (shell expansion syntax, passes through Vite
    unchanged into the built dist).
  - frontend/Dockerfile: install gettext (provides envsubst), snapshot
    /usr/share/nginx/html/index.html → index.html.tpl at build, install
    docker-entrypoint.sh, wire ENTRYPOINT to it. The .tpl is the
    immutable source — every container start re-renders index.html
    from .tpl, so restarts pick up new env values cleanly (no
    accidental "first-boot env stuck forever" trap).
  - frontend/docker-entrypoint.sh: applies defaults if env unset,
    runs envsubst (locked to BRAND_TITLE + BRAND_DESCRIPTION
    explicitly so /assets/*.js template literals aren't touched if
    anyone ever extends substitution to the bundle), execs nginx.
  - frontend/vite.config.ts: drop the htmlTitleDefaults plugin — no
    longer needed since substitution is fully runtime.
  - frontend/.env.example + .env.production.example: drop the
    VITE_DEFAULT_* docs (the vars no longer have effect).
  - docker-compose.yml + docker-compose.production.yml: pass
    BRAND_TITLE / BRAND_DESCRIPTION env into the frontend service
    with sensible defaults so unconfigured installs work unchanged.
  - .env.example: add BRAND_TITLE / BRAND_DESCRIPTION with comment
    pointing at the social-preview use case.

Verified end-to-end against the built image:
  - BRAND_TITLE="Arkan Studio" BRAND_DESCRIPTION="Wedding photographs
    by Arkan Studio" → index.html serves <title>Arkan Studio</title>
    + og:title="Arkan Studio" + og:description correctly substituted.
  - .tpl preserves ${...} tokens so the next restart can re-substitute.
  - Bundle assets unaffected.
  - Defaults applied when env unset → <title>PicPeak</title>.

Docs PR in picpeak-docs describes the two new env vars under
"Social link preview fallback" in the environment-variables reference.

Refs: #521
2026-05-21 10:00:21 +02:00
Paul Nothaft 53139b8cb8 fix(lightbox): pan zoomed image with single-finger touch on mobile (#532)
@Rekoo-PS reported zoom on mobile only shows the centre crop — the
image zooms but you can't pan around to see other parts. Single-finger
touch was being routed through the carousel-swipe branch which is
gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed
the touch hit no handler at all.

Desktop has the equivalent path via handleMouseDown / handleMouseMove
(line 364), which is why this only manifests on mobile.

Add a single-finger pan branch to the touch handlers that mirrors the
mouse path:
  - handleTouchStart: when zoom > 1 and one finger, record dragStart
    relative to the existing dragOffset (so subsequent moves continue
    from where the last pan left off, not from origin).
  - handleTouchMove: when isDragging + zoom > 1 + one finger, update
    dragOffset from touch position.
  - handleTouchEnd: clear the isDragging flag (offset persists so the
    image stays where the user left it).

Also fix a latent bug surfaced while reading the pinch-zoom path:
when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the
photo sat off-centre at natural zoom. Re-centre in handleTouchMove
when newZoom drops to <=1 with a non-zero offset.

Carousel swipe stays disabled when zoomed (existing behaviour). Mouse
path untouched. Pinch-to-zoom path untouched.

Refs: #532
2026-05-21 10:00:21 +02:00
Paul Nothaft b2bbf7efb5 feat(lightbox): save photo to Photos app on mobile via Web Share (#531)
@Jasper2213 reported non-technical clients struggle to get downloaded
photos into their Photos / Gallery app — current flow goes through the
Files folder, requires unzipping for the bulk download, and is hard to
explain over email. Browsers can't write directly to the OS Photos app
(it's a protected location), but navigator.share({ files: [...] }) opens
the native share sheet which on iOS includes "Save Image" and on Android
includes "Save to Photos" / "Save image" — exactly the affordance non-
technical users are looking for.

Plumbed through three layers:

1. galleryService — new savePhotoToDevice(slug, photoId, filename).
   Fetches the photo blob, probes navigator.canShare({ files: [file] })
   with a representative File (some browsers return true for empty
   files arrays even when they won't accept a non-empty one), and:
     - shares if supported,
     - falls back to the existing <a download> path otherwise.
   AbortError on share() means the user dismissed the sheet — that's
   a choice, not a failure, so no fallback. Any other error falls
   through to a regular download so the user still gets the file.
   Refactored the existing downloadPhoto to share the fetch + trigger
   helpers (no behaviour change for the other 3 callers; they keep
   the regular download path).

2. useGallery — new useSavePhotoToDevice() hook next to the existing
   useDownloadPhoto(). Onsuccess toast omitted because the share-sheet
   path doesn't finish from this code's perspective — the OS UI takes
   over and the user picks the destination, so "Photo downloaded" is
   misleading. Fallback path stays silent to keep the two flows
   symmetrical (the file appearing in Downloads is its own signal).

3. PhotoLightbox — swap the existing useDownloadPhoto call site to
   useSavePhotoToDevice. No UI change. Desktop unchanged. Other
   download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView
   bulk) still use useDownloadPhoto — scoping this PR to the
   lightbox download button per the discussion thread.

Browser support:
  - iOS Safari 15+:    Web Share Files → "Save Image" → Photos      ✓
  - Chrome Android:    Web Share Files → "Save to Photos" / "Save"  ✓
  - Desktop Chrome:    canShare returns false → regular download     ✓
  - Desktop Safari:    canShare returns false → regular download     ✓
  - Firefox (any):     no Web Share File support → regular download  ✓

No new tests — the flow is browser-API-driven; jsdom doesn't model
navigator.share or canShare, so a meaningful unit test would mostly
exercise the mock rather than the contract. Verified the build is
clean (tsc --noEmit + vite build both pass).

Refs: #531
2026-05-21 10:00:21 +02:00
Paul Nothaft 600c29db8a fix(lightbox): fill the heart icon when liked (#538 follow-up)
@Tietge86 spotted that both branches of the heart-icon className were
`text-white` — the conditional was a no-op, the `fill-current` class
that would actually fill the icon was missing entirely. The button
background was turning red on like, but the heart icon stayed as a
white outline against the red, making it nearly invisible.

Move text-white outside the conditional (always white against the
red/dark backgrounds the button uses), and add fill-current to the
liked branch so the heart fills in.

Same shape as bug 2 of the original report — the like state needed to
be visually unambiguous. PhotoLikes.tsx was already fixed in this PR;
this catches the equivalent latent bug in the inline lightbox toolbar
button.

Also: bug 4 of the original report (recovery flow) turned out to be
SMTP misconfig on the reporter's end (mailhog silently dropping
emails), not a PicPeak bug. Confirmed in this thread; no further
backend changes needed.

Refs: #538
2026-05-20 17:28:50 +02:00
Paul Nothaft 4d92fb4590 Merge pull request #540 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.2-beta.0
2026-05-20 16:49:45 +02:00
github-actions[bot] 03cf09a7bf chore(beta): release 3.54.2-beta.0 2026-05-20 14:49:19 +00:00
Paul Nothaft c900be92dd Merge pull request #539 from the-luap/fix/guest-feedback-multi-538
fix(feedback): three guest-mode bugs from #538 (filter, like state, count leak)
2026-05-20 16:48:36 +02:00
Paul Nothaft 5311588baf fix(feedback): three guest-mode bugs reported in #538
Picks up three of the four bugs from @Tietge86's report. Bug 4
(recovery flow) needs network-tab data from the reporter before a fix
makes sense; commented on the issue asking for the HTTP status from
POST /gallery/:slug/guest/recover.

Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx)

  The feedback filter was scoping by `photo.like_count > 0`, which is
  the global aggregate across all guests. In guest identity mode the
  filter intent is "show MY picks", so a guest who'd liked photos that
  nobody else had touched got an empty grid.

  Fix: pull the current guest's interactions from /my-feedback (already
  keyed by x-guest-token in the api interceptor) into per-type
  photo-id Sets and filter against those when identity_mode === 'guest'.
  Falls back to the aggregate-count check in simple mode where there's
  no per-person identity to scope by. Same per-guest scoping applied to
  the chip-count labels ("Liked (N)" etc.) so the chip number matches
  what the filter actually surfaces — otherwise the chip says one
  count globally and the filter shows a different (smaller) one, which
  is the same UX cliff #538 originally surfaced.

  The /my-feedback query is gated on isGuestIdentityMode (not on
  filterType being feedback-related) so the chip counts are populated
  on first render. One extra request per gallery load in guest mode;
  payload is tiny.

Bug 2 — Liked state on PhotoLikes button invisible

  bg-red-50 text-red-600 is barely visible against most themes,
  especially dark + brand-coloured backgrounds. Switch to the same
  filled state the lightbox toolbar already uses
  (bg-red-500/80 text-white) so the like registers visually.
  Heart icon's fill-current was already there for the liked state —
  unchanged.

Bug 3 — Aggregate like count leaks in lightbox toolbar

  PhotoLightbox.tsx rendered {likeCount} unconditionally next to the
  inline heart button. When the admin has show_feedback_to_guests off,
  guests still saw how many other guests had liked a photo (the count
  is an admin-only metric in that mode). Gate the span on
  feedbackSettings?.show_feedback_to_guests, matching how the rest of
  the lightbox toolbar treats that toggle. Also added show_feedback_to_guests
  to the local feedbackSettings TS type (backend already returns it).

Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow
plumbing best validated via manual / e2e (the existing my-feedback
endpoint and feedbackService are already covered upstream); bugs 2/3
are CSS and a conditional render.

Refs: #538 (bugs 1, 2, 3 of 4)
2026-05-20 16:42:22 +02:00
rpintodasilva dae47518fb fixes 2026-05-20 13:13:35 +02:00
rpintodasilva fd15be6247 French Transalation - v2 2026-05-20 10:40:24 +02:00
Paul Nothaft cf10da29ca Merge pull request #536 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.1-beta.0
2026-05-20 08:54:14 +02:00
github-actions[bot] baf5f27fb4 chore(beta): release 3.54.1-beta.0 2026-05-20 06:53:31 +00:00
Paul Nothaft 83fc58a523 Merge pull request #535 from the-luap/fix/public-site-dark-theme-contrast
Fix public site contrast for dark themes
2026-05-20 08:53:02 +02:00
paul 8b72721812 fix(public-site): honor dark theme surface colors 2026-05-20 08:48:40 +02:00
Paul Nothaft e0ad7ac2a7 Merge pull request #534 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.0-beta.0
2026-05-20 08:24:46 +02:00
github-actions[bot] 085a2e5fed chore(beta): release 3.54.0-beta.0 2026-05-20 06:24:22 +00:00
Paul Nothaft a0ebc97cdd Merge pull request #533 from the-luap/feat/schema-drift-test-530
fix(install): skip legacy chain on recovery-state DBs + schema-drift CI (#530)
2026-05-20 08:23:57 +02:00
Paul Nothaft 4d3f2470bc ci(schema-drift): handle absent migrations table in precondition (#530)
First CI run failed at the precondition check because the SQL `CASE WHEN
to_regclass(...) IS NULL THEN 0 ELSE (SELECT count(*) FROM migrations)`
expression doesn't short-circuit at parse time — Postgres parses the
subquery against `migrations` even when the outer guard would skip it,
fails the run with "relation 'migrations' does not exist".

initializeDatabase() doesn't create the `migrations` tracking table —
that's the migrate:safe runner's responsibility — so in the recovery
scenario the table genuinely doesn't exist yet. Both "absent table" and
"present but empty table" are valid recovery states.

Split the check into two shell steps: to_regclass first, then count only
if the table exists. Avoids the parse-time subquery error and accepts
either state.
2026-05-19 22:53:30 +02: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
Paul Nothaft bdd973eaf9 Merge pull request #529 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.53.0-beta.0
2026-05-19 07:28:26 +02:00
github-actions[bot] c042a33431 chore(beta): release 3.53.0-beta.0 2026-05-19 05:27:14 +00:00
Paul Nothaft 633a2ae724 Merge pull request #527 from the-luap/fix/bug-batch-518
fix(bug-batch-518): lightbox comments toggle + further fixes
2026-05-19 07:26:54 +02: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 4b4ecfdf71 fix(header): hide language name on mobile to free the title (#523)
@Rekoo-PS reported the LanguageSelector pushing into the company-name
title on narrow viewports — the button always rendered
Globe + flag + full language name (~120px), and on mobile that pinched
the left-side title cluster in AdminHeader.

Wrap the name in `hidden sm:inline` so <sm the button collapses to
just Globe + flag, matching the existing "hidden xl:block" pattern
on the date display in the same header. Self-explanatory at icon-only
width (users see their current flag and a globe), and the dropdown
still shows full names when opened. Title/aria-label keep the name
discoverable for screen readers + tooltip hover on the icon-only state.

Refs: #523
2026-05-18 23:19:42 +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
Paul Nothaft 3465b55abc feat(events): default Guest Feedback ON via admin setting (#520)
@Rekoo-PS asked for an admin-level switch so new events can have Guest
Feedback enabled out of the box instead of toggling it on every time.
Mirrors the existing event_default_require_password pattern (#317) —
same shape end-to-end, same set of five files.

- publicSettings.js: whitelist + expose event_default_feedback_enabled
  (defaults to false to match the prior hard-coded form default; no
  behaviour change for existing installs until an admin flips it).
- adminEvents.js: rename `feedback_enabled = false` destructure to
  `feedback_enabled: feedbackEnabledInput` so we can distinguish
  "omitted" from "explicit false", then resolve the default from the
  setting only when the caller omitted it — identical to the
  require_password handling a few lines above.
- Frontend EventSettings type + state + loader: new boolean,
  default false.
- EventsTab: toggle UI right under "Require password by default".
- CreateEventPage: one-shot useEffect that seeds
  feedback_settings.feedback_enabled from the public setting on first
  load (mirrors the require_password seed effect right above it).
  Sub-toggles (likes / ratings / comments) keep their hard-coded
  true defaults so flipping the master setting immediately gives
  sensible behaviour without a second admin setting to manage.

Refs: #520
2026-05-18 21:26:45 +02:00
Paul Nothaft d44e1adba7 fix(lightbox): hide comments toggle when allow_comments=false (#518)
@Rekoo-PS reported the MessageSquare comment button stayed visible in
the lightbox toolbar even when guest comments were disabled. Same
class of bug as #513 (per-photo Like button missing the master
gate) but on a different control.

The Like and Rating buttons in the lightbox toolbar gate correctly:
  feedbackEnabled && feedbackSettings?.allow_likes
  feedbackEnabled && feedbackSettings?.allow_ratings

The MessageSquare button only checked feedbackEnabled. Since likes
and ratings already have their own inline buttons in the same
toolbar, this third button is effectively the "open comments panel"
affordance — its badge counts comments, its tooltip mentions
comments. When comments are off it has nothing meaningful to do.

Add allow_comments to the local feedbackSettings type (the backend
already returns it via galleryFeedback.js:33) and gate the button on
feedbackEnabled && feedbackSettings?.allow_comments.

Refs: #518
2026-05-18 21:13:03 +02:00
Paul Nothaft 7b8ee1c148 Merge pull request #526 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.52.1-beta.0
2026-05-18 21:04:37 +02:00
github-actions[bot] b2b46d311d chore(beta): release 3.52.1-beta.0 2026-05-18 18:59:57 +00:00
Paul Nothaft 42c5cda38c Merge pull request #519 from the-luap/fix/install-permissions-484
fix(install): self-chowning entrypoint kills fresh-install restart loop (#484)
2026-05-18 20:59:33 +02:00
Paul Nothaft 91d47590ae Merge pull request #524 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.52.0-beta.0
2026-05-18 20:59:14 +02:00
github-actions[bot] db3f1b83ce chore(beta): release 3.52.0-beta.0 2026-05-18 18:56:50 +00:00
Paul Nothaft 2d5a2ad78a Merge pull request #500 from munin92/feat/v1-upload-category-id
feat(api/v1): accept category_id on POST /events/:id/photos
2026-05-18 20:56:17 +02:00
Paul Nothaft 763fd4593f ci(install-smoke): use BusyBox-compatible ps in node-user check
Alpine ships BusyBox ps (no -p PID, no pgrep), which failed CI on the
first run of this workflow with "ps: unrecognized option: p". Replace
the pgrep-then-ps chain with `ps -o user,comm | awk '$2=="node"'`
which works on both BusyBox (Alpine, in the container) and procps
(the GitHub runner host, though we don't use it here).
2026-05-18 10:28:29 +02:00
Marian df83b3e923 test(api/v1): cover category scoping clause + 400 response
Unit test for the v1 upload route's category lookup, requested in
the PR review. Mocks db (chainable, mirroring src/routes/__tests__/
adminAuth.test.js) plus apiTokenAuth/requireApiScope (pass-through)
and multer (stub req.file). Two cases:

1. The scoping clause: the andWhere callback applied to a knex
   builder spy produces .where({event_id: <event.id>}).orWhere(
   'is_global', true) — exactly the contract the reviewer asked
   for, exercising the OR-clause rather than just asserting the
   callback was passed.
2. Null lookup result yields 400 with "Unknown or out-of-scope
   category_id <N>".

No v1 jest scaffolding existed before, but the project-wide harness
(backend/jest.config.js + jest.setup.js) already covers the new
file via testMatch '**/__tests__/**/*.test.js'. Happy-path tests
deferred — would require stubbing fs/sharp/imageProcessor/share
linkService and several more db chains, which the reviewer was
willing to accept as a separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 92bb9e1a12 fix(api/v1): scope category lookup to event_owned or global
PR review pointed out the original lookup
  db('photo_categories').where({ id: parsedCategoryId }).first()
accepted any category id — including one that belongs to a different
event. photo_categories carries both event_id (per-event) and is_global
(see backend/migrations/legacy/004_add_categories_and_cms.js); the v1
upload route should require either match.

Not a privilege issue (apiTokenAuth.js inherits the admin's powers, no
per-event scoping), but it lets a misconfigured uploader silently file
photos under a category the target event doesn't own — and the 201 echo
includes a category_id that makes no semantic sense.

Tighten to:
  .where({ id: parsedCategoryId })
  .andWhere(function () {
    this.where({ event_id: event.id }).orWhere('is_global', true);
  })
…and update the 400 message to "Unknown or out-of-scope category_id N".

OpenAPI description already documents the intended scope.

Tests deferred to a follow-up; v1 has no jest harness today, see PR
discussion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 6901e2661e feat(api/v1): accept category_id on POST /events/:id/photos
The v1 photo upload endpoint previously ignored any caller-supplied
category and inserted photos with category_id=NULL. That meant
programmatic uploads via API tokens (e.g. a photobox sidecar) landed
in picpeak as uncategorized, forcing operators to bulk-assign category
in the admin UI after each event.

Mirror the adminPhotos.js category-handling logic on v1:
- Read optional `category_id` from the multipart form body.
- Reject unknown ids with 400 (with the id in the error) so callers
  fail fast on misconfigured envs instead of silently uncategorized
  uploads.
- Set photos.category_id on insert.
- Flip photos.type to 'collage' when the category's slug is
  collage/collages, matching adminPhotos.

Backwards-compatible: omitting category_id keeps the prior behavior
(insert with NULL category, type='individual'). OpenAPI spec + 201
response body updated to include the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Paul Nothaft 1505775678 fix(install): self-chowning entrypoint kills fresh-install restart loop (#484)
The fresh-install restart loop reported by @MrGabri (and confirmed by
@AloePacci with the user:0:0 workaround) had a clear root cause:

  - Dockerfile pinned USER nodejs (UID 1001) before the entrypoint
    ran, so the existing chown branch in init-production.sh:13 was
    dead code.
  - wait-for-db.sh (the actual entrypoint, not init-production.sh)
    silently swallowed mkdir/EACCES on bind mounts with || true,
    then a downstream migration error surfaced as the visible failure.
  - Net effect on a typical Linux host where the bind-mount dir is
    owned by UID 1000: container can't write, exits non-zero,
    restarts forever with no clear error.

Switch to the standard Docker drop-privileges pattern:

  1. Install su-exec, drop `USER nodejs` from the Dockerfile —
     container now starts as root.
  2. wait-for-db.sh: if running as root, chown /app/storage,
     /app/data, /app/logs to nodejs and re-exec self via
     su-exec nodejs:nodejs. App still ends up running as UID 1001.
  3. Preflight check for non-root invocations (compose `user:`
     overrides): verify the bind mounts are actually writable
     before continuing. If not, exit 1 immediately with an
     actionable error pointing at the docs — no more silent
     restart loops.

Also:

  - Delete backend/init-production.sh. It was an orphan — no caller
    in the Dockerfile, compose, or anywhere else. Its chown logic
    looked authoritative enough that @MrGabri ran it manually trying
    to debug, which is what finally surfaced the EACCES.
  - docker-compose.yml: drop user: + PUID/PGID env. The pattern-B
    UID-matching workaround they implemented is obsolete now that
    pattern A (root-then-drop) is in place.
  - .env.example + README: drop PUID/PGID documentation.
  - Add fresh-install smoke test workflow. Boots backend + postgres
    against bind mounts owned by UID 1000 (the GitHub runner UID,
    and the common-mismatch case on Linux hosts) and verifies:
    + container reaches healthy without restart-looping
    + chown happened (dirs now owned by 1001 inside the container)
    + node runs as nodejs, not root (su-exec drop worked)
    + /health returns status:ok
    + with --user 5005:5005 + unwritable mounts, preflight exits
      loud with the expected error string

Verified locally end-to-end against a fresh Postgres + UID-501-owned
bind mount: backend reaches healthy in ~20s, chown applied, node
runs as nodejs, no restart loop. Docs in picpeak-docs cover the new
behavior + a Troubleshooting section for the install-path bugs
fixed in #484/#494/#511/#488.

Refs: #484
2026-05-17 22:29:29 +02:00
Paul Nothaft 2619049a95 Merge pull request #517 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.5-beta.0
2026-05-17 09:29:01 +02:00
github-actions[bot] acb387f69c chore(beta): release 3.51.5-beta.0 2026-05-17 07:28:47 +00:00
Paul Nothaft ebc7da21be Merge pull request #503 from filpgame/fix/email-language-json-parse
fix(email): parse JSON-encoded language setting before using as locale
2026-05-17 09:28:25 +02:00
Paul Nothaft afcdf0d389 Merge pull request #516 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.4-beta.0
2026-05-17 09:27:19 +02:00
github-actions[bot] 4da6726e57 chore(beta): release 3.51.4-beta.0 2026-05-17 07:26:57 +00:00
Paul Nothaft a747eb351d Merge pull request #502 from filpgame/fix/category-slug-diacritics
fix(categories): strip diacritics from auto-generated slugs
2026-05-17 09:26:39 +02:00
Paul Nothaft 4fc07d9282 Merge pull request #515 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.3-beta.0
2026-05-17 01:20:56 +02:00
github-actions[bot] 941453fd7f chore(beta): release 3.51.3-beta.0 2026-05-16 23:19:50 +00:00
Paul Nothaft 482e91bbf8 Merge pull request #501 from filpgame/fix/settings-page-language-reset
fix(i18n): settings page resets UI language to server default
2026-05-17 01:19:27 +02:00
Paul Nothaft aa8cca165d Merge pull request #514 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.2-beta.0
2026-05-17 01:02:48 +02:00
github-actions[bot] 5a9ca4ad57 chore(beta): release 3.51.2-beta.0 2026-05-16 23:02:10 +00:00
Paul Nothaft aac60fa895 Merge pull request #513 from the-luap/fix/bug-batch
fix/feat: bug batch — drag-drop, lightbox, likes, downloads, upload, i18n (#504-510)
2026-05-17 01:01:49 +02:00
Paul Nothaft 51890e1aa5 fix(i18n): drive customer "Preferred language" select from SUPPORTED_LANGUAGES (#510)
Audit follow-up to the Spanish-locale commit: `CustomerDetailPage` had
a hardcoded `<option>` list for the customer's preferred-language
selector — 5 entries (en/de/nl/pt/ru) that were missing both fr (an
existing gap) and es (the new one). Every other language selector in
the frontend (the navbar `LanguageSelector`, the `GeneralTab` default-
language dropdown, the `EmailConfigPage` per-language tabs) already
reads from the shared `SUPPORTED_LANGUAGES` constant, so adding es
there was enough for those. This one had drifted.

Now mapped from `SUPPORTED_LANGUAGES` so future locales only need to
touch one place.
2026-05-17 00:57:03 +02:00
Paul Nothaft 1e7806961f Merge pull request #512 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.1-beta.0
2026-05-17 00:53:34 +02:00
github-actions[bot] 2e828fcf9f chore(beta): release 3.51.1-beta.0 2026-05-16 22:53:14 +00:00
Paul Nothaft 99e60a2433 Merge pull request #511 from the-luap/fix/install-postgres-log-noise
fix(install): silence clean-install postgres log noise (#484)
2026-05-17 00:52:48 +02:00
Paul Nothaft 061712ebf1 feat(i18n): add Spanish (es) locale (#510)
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.
2026-05-17 00:50:51 +02:00
Paul Nothaft 98f3c3df41 fix(upload): restore configurable batch-size for reverse proxies (#509)
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.
2026-05-17 00:42:22 +02:00
Paul Nothaft 33de294d57 feat(lightbox): surface original camera filenames (#508)
Photographers running the gallery as a client-selection tool want to
map a guest's picks back to source files for retouching. The
`general_use_original_filenames_for_downloads` toggle (#493) already
does this on the download side; this extends the same toggle to the
in-lightbox view so the camera filename is visible alongside the
photo while it's being looked at.

Tied to the same toggle on purpose — one switch controls both
surfaces. Off by default; existing galleries keep showing only the
position counter.

Wiring:
- gallery.js serializes `photos[].original_filename` and surfaces the
  resolved toggle as `event.use_original_filenames` so the client can
  decide whether to render it.
- The bespoke `PhotoLightbox` renders the original filename (falling
  back to the storage filename only for pre-migration-062 uploads) in
  a muted line under the position counter, truncated to keep the
  toolbar tidy.
- `GalleryStoryLayout` mounts the same `PhotoLightbox`, so its
  rendering follows along.
- `GalleryPremiumLayout` uses `yet-another-react-lightbox` instead;
  added the Captions plugin and a `title` field on the slides so the
  same name appears as a caption when the toggle is on.

The remaining layouts feed back into the main `PhotoLightbox` via
`PhotoGridWithLayouts`, so the prop reaches them through the layout
props bag.
2026-05-17 00:35:16 +02:00
Paul Nothaft 38343e62de fix(downloads): apply original-filename toggle to individual downloads too (#507)
Follow-up to #498. The toggle reached zip downloads but single-photo
downloads still landed on disk with the renamed `event_individual_NNN.jpg`
even when the admin had flipped the setting on. Two reasons, fixed
in lockstep:

- Frontend overrode the server's Content-Disposition with a hardcoded
  `<a download="X">` attribute (`gallery.service.ts`, `photos.service.ts`)
  where X was the sanitized `photo.filename` known to the client. So
  the backend's correctly-formed `Content-Disposition` never reached
  the disk write. Added `parseContentDispositionFilename` (RFC 5987 +
  plain `filename=` fallback) and let the server name win when present.
- `secureImages.js` (enhanced/maximum protection's secure-download
  route) was missed in #498 and still emitted a hardcoded
  `filename="${photo.filename}"` regardless of the toggle. Wired it
  through `getUseOriginalFilenames` + `buildContentDisposition` so it
  matches the regular gallery download path.

Also exposed `Content-Disposition` via CORS so split (cross-origin)
frontend deployments can still read it from JavaScript. Same-origin
Docker deploys already had access; this is a defensive addition for
the split case.
2026-05-17 00:25:12 +02:00
Paul Nothaft 9d2db9a73b fix(gallery): hide Like button when guest feedback is off (#506)
Four gallery layouts were rendering the per-photo Like button without
gating on the master "Guest Feedback" toggle, so a guest still saw a
heart icon and could submit likes on events where the host had turned
feedback off. The other layouts (Grid / Justified / Masonry / Story)
already gated correctly with `feedbackEnabled && allowLikes` —
Rekoo-PS's note that "it's hidden in some themes" matches that split.

- CarouselGalleryLayout, MosaicGalleryLayout, TimelineGalleryLayout:
  the existing conditional checked only `feedbackOptions?.allowLikes`,
  missing the `feedbackEnabled` master gate. Added it inline.
- GalleryPremiumLayout: the per-card Like button rendered
  unconditionally because PhotoCard never received the allow-likes
  signal. Added an `allowLikes` prop on PhotoCardProps, plumbed
  `feedbackOptions?.allowLikes` down from the parent, and wrapped the
  button in `feedbackEnabled && allowLikes`.

The follow-up "default guest-feedback ON" request from Rekoo-PS in
the comments is a separate feature (admin > General > Event Creation
default) and out of scope for this fix.
2026-05-17 00:13:21 +02:00
Paul Nothaft d2d55098d6 fix(lightbox): align swipe-neighbour height + stop black flash on commit (#505)
Two adjacent swipe-time defects, one diagnosis each:

1. Height differed between current and neighbouring slides during a
   swipe but matched when the arrow buttons advanced the carousel.
   Cause: neighbour slides wrap their image in a div with extra `px-2`
   horizontal padding while the current slide does not. `object-contain`
   then sees a narrower container on neighbours, so wide images cap on
   width first and render shorter than the same image at the current
   position. Removed the padding so both slots share the same container
   geometry. Arrow-button navigation looked fine because it never
   showed the neighbour layout side-by-side.

2. The image flashed black for ~100–400 ms each time a swipe committed
   to the next slide. Cause: the 3-slide track has no React keys, so
   React reconciled slides by position. After commit the photo at every
   position changed (`prev → current → next` shifts left), every slot's
   `<AuthenticatedImage>` saw a new `src` prop, and its fetch effect
   restarted from the placeholder state — including the slot that was
   the user's "next" slide a moment ago and held a fully-loaded image.
   Added a stable `key` derived from `photo.id` so React MOVES existing
   DOM nodes across slots instead of refetching. 2-photo galleries are
   a key-collision edge case (`prev === next`), so they fall back to
   slot-prefixed keys to keep siblings unique; behaviour there is no
   worse than today.
2026-05-17 00:09:15 +02:00
Paul Nothaft 577c4bdf29 fix(upload): wire drag-and-drop on admin + user upload zones (#504)
The dashed-border upload area in `PhotoUpload` (admin) and
`UserPhotoUpload` (gallery user-upload) is styled and labelled as a
drop zone — every locale's `upload.clickToUpload` already reads
"Click to upload or drag and drop" or its translation — but neither
component had any `onDragOver` / `onDragEnter` / `onDragLeave` /
`onDrop` handlers. Files dropped on the zone fell through to the
browser's default behaviour (open the image in a new tab), which is
what Rekoo-PS reported.

Added native HTML5 drag-and-drop wiring on both components, plumbed
through the same filter/limit/toast pipeline used by the click path
(`addFiles` helper). Visual highlight on drag-over via an `isDragOver`
flag; the listener guards against the `dragleave` strobing that fires
on every child node. Also reset the `<input>` value after onChange so
re-picking the same file still triggers an upload — matches the
new drop-then-pick mental model.
2026-05-17 00:02:39 +02:00
Paul Nothaft 86b33d4dda fix(install): silence clean-install postgres log noise (#484)
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.
2026-05-16 23:56:05 +02:00
filpgame f12062f1e7 fix(email): parse JSON-encoded language setting before using as locale
general_default_language is stored as a JSON string (e.g. "\"pt\"").
getRecipientLanguage() returned the raw value including quotes, causing
the translation lookup to miss every match and fall back to English.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:31:15 -03:00
filpgame 848430e72b fix(categories): strip diacritics from auto-generated slugs
Accented chars (ã, ç, é, etc.) were silently dropped by the slug
regex because \w only matches ASCII. NFD decomposition + combining
mark removal converts them to ASCII equivalents instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:23:03 -03:00
filpgame 165ebce8d1 fix: settings page resets UI language to server default
When navigating to the Settings page, useSettingsState called
i18n.changeLanguage() with the server-stored general_default_language
value on every settings query resolution. This caused the admin UI
language to reset to the server default (e.g. "en") regardless of the
language the user had selected via the LanguageSelector.

The general_default_language setting is intended as the default for
public galleries, not for controlling the admin UI language. The admin
UI language is already persisted via localStorage through
i18next-browser-languagedetector and should not be overridden by server
settings.

Remove the i18n.changeLanguage() call from the useEffect that
initialises settings state from the API response.
2026-05-16 01:14:57 -03:00
Paul Nothaft 3a490844e1 Merge pull request #499 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.0-beta.0
2026-05-14 23:30:58 +02:00
github-actions[bot] 72c2b5c796 chore(beta): release 3.51.0-beta.0 2026-05-14 21:25:07 +00:00
Paul Nothaft 826e43ebac Merge pull request #498 from the-luap/feat/lightbox-preview-tier-492
feat(downloads): preserve original camera filenames on download (opt-in) (#493)
2026-05-14 23:24:41 +02:00
Paul Nothaft 019b0c0301 Merge pull request #497 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.50.0-beta.0
2026-05-14 23:22:44 +02:00
Paul Nothaft 7eeef2ba98 feat(downloads): preserve original camera filenames on download (opt-in) (#493)
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.

- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
  so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
  collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
  invalidated when the toggle flips so the next download rebuilds with the
  new names.
- Falls back to the storage filename whenever `original_filename` is null
  (legacy uploads predating migration 062).
2026-05-14 23:11:00 +02:00
github-actions[bot] 365582e678 chore(beta): release 3.50.0-beta.0 2026-05-14 20:44:57 +00:00
Paul Nothaft 3083c748b9 Merge pull request #496 from the-luap/feat/lightbox-preview-tier-492
feat(lightbox): medium-resolution preview tier (#492)
2026-05-14 22:44:35 +02:00
Paul Nothaft 61f1d13210 feat(lightbox): medium-resolution preview tier (#492)
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.
2026-05-14 22:30:39 +02:00
Paul Nothaft 06b33ced94 Merge pull request #495 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.6-beta.0
2026-05-14 21:37:55 +02:00
github-actions[bot] d4198e1bbc chore(beta): release 3.49.6-beta.0 2026-05-14 19:37:31 +00:00
Paul Nothaft 62b3ed6364 Merge pull request #494 from the-luap/fix/postgres-fresh-install-fk-order
fix(install): defer events.hero_photo_id FK to break circular reference (#484)
2026-05-14 21:37:00 +02:00
Paul Nothaft 87834a7fff fix(install): defer events.hero_photo_id FK to break circular reference (#484)
Real root cause behind MrGabri's fresh-Postgres install crash, surfaced
by his second log dump after #488 silenced the FATAL noise:

  Initial setup failed: error: alter table "events" add constraint
  "events_hero_photo_id_foreign" foreign key ("hero_photo_id")
  references "photos" ("id") on delete SET NULL
  - relation "photos" does not exist

initializeDatabase() in src/database/db.js declared the FK inline at
events createTable (line 89), but the photos table is created later
in the same function (line 203). On Postgres this is a hard error —
the referenced table must exist at FK-declaration time. SQLite
silently tolerated it because its FK enforcement is lazy and the
inline declaration just became a column with no FK metadata.

Why no existing Postgres install hit it: initializeDatabase only
runs the createTable block on `if (!hasEventsTable)`. Once a
deployment has the events table from any prior run, the path is
skipped. So the bug only ever fires on a truly fresh Postgres
install — which is exactly MrGabri's scenario, and which our smoke
suite never exercises (it runs against a long-lived dev stack).

Fix:

- events createTable: drop the inline FK; column declared as a plain
  integer with an explainer comment.
- After both tables exist (post photos createTable): db.schema
  .alterTable('events').foreign('hero_photo_id').references...
  Wrapped in a try/catch that swallows "already exists" so re-runs
  on installs that previously got into a half-state don't fail boot.

Verified by docker compose down -v + up against the dev stack — no
FK error, all migrations apply, FK present in pg_constraint with
the expected definition.
2026-05-14 21:21:23 +02:00
Paul Nothaft 4225cd153f Merge pull request #491 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.5-beta.0
2026-05-14 21:02:58 +02:00
github-actions[bot] 1e4f762871 chore(beta): release 3.49.5-beta.0 2026-05-14 19:02:43 +00:00
Paul Nothaft d300426390 Merge pull request #490 from the-luap/fix/admin-users-sqlite-date-crash
fix(admin-users): normalise date fields to ISO across DB drivers (#485)
2026-05-14 21:02:13 +02:00
Paul Nothaft b6b58d0659 fix(admin-users): normalise date fields to ISO across DB drivers (#485)
Admin > Users page crashed with "TypeError: e.split is not a function"
on native installs (SQLite default). Reported by @blazmaric in #485
with a clean diagnosis: SQLite returns lastLogin / createdAt /
updatedAt as integer milliseconds since epoch, while Postgres
returns ISO strings via the standard JSON serialiser. The page used
parseISO() on the raw value and parseISO trips on numbers.

Fix at both layers — defence in depth:

- backend/src/routes/adminUsers.js: new toIso() helper applied in
  transformUser + transformInvitation. Coerces Date / number /
  numeric-string / null to a single ISO 8601 string contract before
  the response leaves the API. Protects every consumer (frontend
  AND external API tokens / n8n) regardless of which DB driver is
  underneath.
- frontend/src/services/userManagement.service.ts: same helper as
  defence-in-depth for stale backends mid-deploy and any cached
  pre-fix response shape. Also surfaced an existing
  transformInvitation gap — invitations endpoints were returning
  raw response.data.invitations without going through the
  transformer.

10 unit tests pin the toIso contract: all known driver shapes
(Date, number, numeric-string, ISO-string, null/undefined/empty)
plus the full transformer paths for transformUser and
transformInvitation.

Out of scope: same epoch-ms surface may exist on other admin pages
that were never tested against SQLite (events list, customers,
webhooks, api tokens, activity log). Worth a follow-up audit pass
to apply toIso() in every snake_case→camelCase transformer the
admin routes use, but the immediate Users-page crash is the only
reported one and shipping that fix unblocks @blazmaric.
2026-05-14 20:53:10 +02:00
Paul Nothaft a135544cab Merge pull request #489 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.4-beta.0
2026-05-14 20:50:23 +02:00
github-actions[bot] 57ea08e1ed chore(beta): release 3.49.4-beta.0 2026-05-14 18:49:31 +00:00
Paul Nothaft d39406b241 Merge pull request #488 from the-luap/fix/install-healthcheck-noise-and-stale-workers
fix(install): silence pg healthcheck noise + drop legacy workers container (#484)
2026-05-14 20:49:01 +02:00
Paul Nothaft d4155c4611 fix(install): drop racy migration step + add missing frontend container (#484)
Two follow-up fixes inside the same install-experience surface as
the previous commit:

1. **Removed `docker compose exec -T backend npm run migrate`** in
   both install_docker and update_docker_installation. The backend
   container's wait-for-db.sh already runs `npm run migrate:safe`
   on startup; the script was racing it with a separate (and
   non-safe) `npm run migrate`. That race is the most likely
   actual mechanism behind #484's "relation 'photos' does not
   exist" error on the second install attempt — partial schema
   visible to one of the two parallel migrators. Replaced with a
   bounded wait for the backend container to become healthy
   (Docker healthcheck reports green only after wait-for-db.sh
   finishes its migration pass).

2. **Added the missing frontend container** to the script-generated
   compose. The script previously generated a postgres + redis +
   backend stack with no frontend at all (backend on host port
   3001), while the documented production install
   (docker-compose.production.yml) ships postgres + redis +
   backend + frontend (nginx /api proxy on host port 3000). That
   shape divergence is half of issue B in #484 — script-installed
   admins had no frontend container and were left wondering where
   the UI lived. Aligning both compose files on the same shape
   eliminates the divergence; the frontend uses curl in its
   healthcheck (frontend/Dockerfile explicitly `apk add curl`)
   unlike the backend.

The remaining piece of issue B — picking ONE canonical install
path (build-from-source script vs. prebuilt-image production
compose) and deprecating the other — is a deployment-strategy
call that deserves its own design pass. Both paths now produce
architecturally-equivalent stacks.
2026-05-14 20:35:54 +02:00
Paul Nothaft 0b0b1bb2d5 fix(install): silence pg healthcheck noise + drop legacy workers container (#484)
Three install-experience bugs that compounded into MrGabri's "fresh
install fails" report:

1. **postgres healthcheck noise.** `pg_isready -U <user>` without
   -d defaults to probing a database whose name matches the user.
   Since DB_NAME defaults to picpeak_prod (not picpeak), every
   healthcheck interval logged
     FATAL: database "picpeak" does not exist
   into postgres logs even though the install was working
   correctly. Reporter saw the FATAL, assumed broken, restarted
   with DB_NAME=picpeak, hit a tainted-state migration error on
   the second try, filed a bug. Fixed in both
   docker-compose.production.yml and the inline compose generated
   by scripts/picpeak-setup.sh — pin -d to ${DB_NAME} so the
   probe hits the real database.

2. **backend container shows perpetually `unhealthy`.** Both
   compose files used `curl -f` for the backend healthcheck, but
   backend/Dockerfile only installs dumb-init + postgresql-client +
   ffmpeg — no curl. Switch to wget --no-verbose --tries=1 --spider
   to match what backend/Dockerfile's own HEALTHCHECK already
   does. Now docker ps, docker compose ps, and the backend image's
   built-in healthcheck all agree.

3. **stale separate `workers` container.** scripts/picpeak-setup.sh
   still generated a second container running `npm run workers`
   alongside the backend, but workers (fileWatcher,
   expirationChecker, emailQueueProcessor, backgroundProcessor,
   webhookWorker) have been started by server.js in-process for
   a while — see the comment at line ~895 of the same script for
   the systemd-side cleanup. The duplicate container caused two
   file watchers and two expiration checkers to compete for the
   same DB rows. Removed from the generated compose; install +
   upgrade paths now stop and rm any pre-existing picpeak-workers
   container.

Doesn't address issue B's bigger architecture mismatch (the script
generates a build-from-source compose with no frontend container,
while docker-compose.production.yml uses prebuilt images with a
separate frontend container). That deserves its own design pass
to pick a canonical install path and align — out of scope here.
2026-05-14 20:31:25 +02:00
Paul Nothaft 409ddf9c93 Merge pull request #487 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.3-beta.0
2026-05-14 20:24:30 +02:00
github-actions[bot] bc58520cd2 chore(beta): release 3.49.3-beta.0 2026-05-14 18:18:43 +00:00
Paul Nothaft d1034ce1c6 Merge pull request #486 from the-luap/fix/promo-banner-alignment
fix(promo-banner): center by default + admin alignment selector (#482)
2026-05-14 20:18:07 +02:00
Paul Nothaft a803491cf4 fix(promo-banner): center by default + admin alignment selector (#482)
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.
2026-05-14 20:10:14 +02:00
Paul Nothaft 3869e5c0dc Merge pull request #481 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.2-beta.0
2026-05-13 18:48:12 +02:00
github-actions[bot] 1cbb0c4cff chore(beta): release 3.49.2-beta.0 2026-05-13 16:46:19 +00:00
Paul Nothaft 6750f5d3b0 Merge pull request #480 from the-luap/fix/ci-trivy-platform-pin
fix(ci): pin TRIVY_PLATFORM per matrix arch (post-#477 follow-up)
2026-05-13 18:45:53 +02:00
Paul Nothaft c3256dc6bf fix(ci): pin TRIVY_PLATFORM per matrix arch (post-#477 follow-up)
PR #477 moved Trivy from the merge-* job into the per-arch build-*
matrix scanning by digest. The amd64 leg works; the arm64 leg
crashes with:

  remote error: no child with platform linux/amd64 in index
  ghcr.io/.../<image>@sha256:<digest>

Root cause: docker/build-push-action wraps every push in an OCI
index — the actual image manifest sits next to a SLSA provenance
attestation manifest as siblings under the digest. Trivy's remote
backend defaults to linux/amd64 when resolving an index, so:

  - amd64 leg → looks for amd64 child → finds the amd64 image → ok.
  - arm64 leg → looks for amd64 child → finds NO amd64 child
    (the only platform child is arm64) → fails.

Fix: set TRIVY_PLATFORM = ${{ matrix.platform }} on each leg's
Trivy step. Each scanner then asks for its own arch and finds it.
SLSA provenance attestation stays attached to the per-arch images
— a real win for supply-chain visibility we'd lose if we'd
disabled provenance instead.

amd64 was the only thing keeping CI partly green; this restores
full green across both legs without touching the build artifact
shape.
2026-05-13 18:41:49 +02:00
Paul Nothaft f9284010b7 Merge pull request #479 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.1-beta.0
2026-05-13 18:35:45 +02:00
github-actions[bot] e5ef893395 chore(beta): release 3.49.1-beta.0 2026-05-13 16:31:12 +00:00
Paul Nothaft 1144e9d162 Merge pull request #477 from the-luap/fix/ci-trivy-multi-arch-scan
fix(ci): scan multi-arch images per-arch by digest, pin trivy-action (#476)
2026-05-13 18:30:41 +02:00
Paul Nothaft f53b10dc2c Merge pull request #478 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.0-beta.0
2026-05-13 18:30:12 +02:00
github-actions[bot] b3c6508712 chore(beta): release 3.49.0-beta.0 2026-05-13 16:18:38 +00:00
Paul Nothaft d856340f0d Merge pull request #475 from the-luap/feat/og-share-cover-photo
feat(og): per-event opt-in to use hero photo as social-share preview (#474)
2026-05-13 18:18:02 +02:00
Paul Nothaft 40e176cb46 fix(ci): trivy-action tag is v0.36.0 (was 0.28.0 — does not exist)
Initial pinning shipped a tag that doesn't exist in the
aquasecurity/trivy-action repo. Workflow run failed with:

  Unable to resolve action 'aquasecurity/trivy-action@0.28.0',
  unable to find version '0.28.0'

The repo's tags use a v prefix (v0.36.0, v0.35.0, …). Bumping
both occurrences (build-backend and build-frontend matrix jobs)
to v0.36.0, which is the latest stable as of 2026-04-22.
2026-05-13 17:59:38 +02:00
Paul Nothaft caf0d61857 fix(ci): scan multi-arch images per-arch by digest, pin trivy-action (#476)
Resolves the intermittent "no child with platform linux/amd64 in
index" failure on the merge-backend job — and fixes the same latent
bug on merge-frontend before it surfaces.

Two compounding root causes per Luca's diagnosis:

1. aquasecurity/trivy-action@master was unpinned, so the action and
   its bundled Trivy binary float on every CI run. A green build
   could flip red overnight without a single repo change.
2. Trivy was asked to scan a multi-platform OCI index by tag (the
   merge-* jobs ran AFTER manifest creation). Its remote resolver
   cannot reliably pick the right per-arch child out of an index
   reference — it needs a single-platform reference (digest, or a
   --platform flag).

Fix:

- Move the Trivy + upload-sarif steps OUT of merge-backend /
  merge-frontend and INTO the per-arch build-backend / build-frontend
  matrix jobs. Each leg scans the image it just pushed by its
  sha256 digest (`...@${{ steps.build.outputs.digest }}`), which is
  always single-platform by construction.
- Pin aquasecurity/trivy-action@0.28.0 (was @master).
- Distinct SARIF category per arch
  (`backend-vulnerabilities-linux-amd64`, …-arm64) so an
  amd64-only finding in a base layer doesn't get masked by the
  arm64 scan in the Security tab.
- Move security-events: write down to the build-* jobs (where the
  scan now runs) and remove it from the merge-* jobs (which only
  publish the manifest now).

Out of scope: flipping `exit-code: '1'` to actually gate CI on
findings. Worth doing as a separate follow-up after an audit pass —
landing it here would surprise beta with a red build for any
pre-existing CRITICAL/HIGH in current images. Inline TODO in the
workflow notes the deferral.
2026-05-13 17:56:29 +02:00
Paul Nothaft 0bc7e2af17 feat(og): per-event opt-in to use hero photo as social-share preview (#474)
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.
2026-05-13 13:47:02 +02:00
Paul Nothaft 16e4d191c2 Merge pull request #473 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.48.1-beta.0
2026-05-12 23:24:51 +02:00
github-actions[bot] fa71e7ea1e chore(beta): release 3.48.1-beta.0 2026-05-12 20:57:36 +00:00
Paul Nothaft 7126f12567 Merge pull request #472 from the-luap/followup/470-tests-and-cache-headers
test+fix(customer-portal): #470 review follow-ups (test coverage + cache headers)
2026-05-12 22:57:07 +02:00
Paul Nothaft 3122dd08a8 fix(customer-routes): Cache-Control: no-store on customer endpoints (#470)
The trigger: PR #458 mounted requireCustomerPortalEnabled which
410'd every /api/customer/* + /api/admin/customers/* request when
the master toggle was off. Some browsers cached that 410 (no
Cache-Control header was set, so heuristic freshness applied —
the wrong default for an authenticated/sensitive surface).
PR #470 reverted the middleware, but a customer whose tab cached
the 410 still saw 410s until they hard-refreshed.

Add noStoreCache middleware and mount it in front of both route
groups. Every response (200, 4xx, 5xx) now carries
`Cache-Control: no-store, no-cache, must-revalidate, private`
plus the HTTP/1.0 Pragma + Expires fallbacks. Any future
transient error from these endpoints can no longer get pinned in
browser or proxy caches and outlive its cause.

Cost is one setHeader per request; applied per route group rather
than globally so static assets + galleries keep their own caching
strategy unchanged.

Includes a dedicated unit test pinning the header set so a future
cleanup pass can't quietly drop it and re-introduce the bug.
2026-05-12 22:53:49 +02:00
Paul Nothaft 5e86eef4f8 test(gallery): verifyGalleryAccess customer-assignment revocation (#470)
4 unit tests pinning the contract of the customer-minted JWT
re-check added in #470:

- via='customer' + customerId, assignment present → next() runs.
- via='customer' + customerId, assignment removed → 403 with
  CUSTOMER_ASSIGNMENT_REVOKED code.
- customerId in payload but `via` claim missing → no re-check
  (defends against a future refactor accidentally widening the
  gate to match every legacy session that happens to carry a
  customerId field).
- per-event-password JWT (no via, no customerId) → no
  event_customer_assignments query at all (asserted by counting
  db() invocations — a regression that quietly added a re-check
  here would 403 every guest the moment any unrelated customer
  was unassigned from any event).

Same mock pattern as customerAuth.middleware.test.js. The re-check
is the load-bearing piece behind the "Manage galleries" dialog
UX promise — these tests guard it explicitly.
2026-05-12 22:53:23 +02:00
Paul Nothaft 7a9c4ca44e test(customers): unit-cover setAssignmentsForCustomer (#470 follow-up)
5 new tests covering the diff math (added/removed), the
archived-event filter, the no-op short-circuit when wanted equals
existing, and the type-coercion of the wanted-list input. Mirrors
the existing setAssignmentsForEvent suite shape so the inverse-
direction service function carries equivalent regression coverage.

This function is the writer behind the "Manage galleries" dialog
and the verifyGalleryAccess re-check together form the access-
control story for the whole feature — getting the diff math
wrong here means assignments don't actually revoke, which is the
entire promise of the new UI.
2026-05-12 22:53:05 +02:00
Paul Nothaft e78e0d957a Merge pull request #471 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.48.0-beta.0
2026-05-12 22:48:50 +02:00
github-actions[bot] dafc8d041a chore(beta): release 3.48.0-beta.0 2026-05-12 20:48:26 +00:00
Paul Nothaft 9be9296eb5 Merge pull request #470 from Luca-Timo/feat/customer-detail-section-order
feat(customers): "Manage galleries" dialog with immediate access revocation + section reorder + portal-flag revert
2026-05-12 22:48:01 +02:00
Luca c02c947463 feat(customers): email customer when admin adds new gallery access 2026-05-12 01:25:12 +02:00
Luca 3f4419356a revert(customer-portal): make the global flag UI-only, drop the kill-switch middleware 2026-05-12 00:40:17 +02:00
Luca 9e418c759c fix(customer): don't log customer out on transient session-refresh errors 2026-05-12 00:07:50 +02:00
Luca d0ad9879bd chore(customers): keep search query after add + add explicit clear button 2026-05-11 23:50:23 +02:00
Luca 6d1af7a011 feat(customers): "Manage galleries" dialog on customer detail page 2026-05-11 23:40:07 +02:00
Luca 55a5846f6f feat(gallery): revoke customer-minted JWTs when assignment is removed 2026-05-11 23:39:47 +02:00
Luca 5377b88e0e feat(customers): replace-assignments endpoint for a single customer 2026-05-11 23:39:22 +02:00
Luca 592fa1e1c2 chore(customers): reorder customer detail sections for browse-first flow 2026-05-11 23:20:39 +02:00
Paul Nothaft 0fd7ea336f Merge pull request #469 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.47.2-beta.0
2026-05-11 22:16:12 +02:00
github-actions[bot] a5e58d085e chore(beta): release 3.47.2-beta.0 2026-05-11 20:15:57 +00:00
Paul Nothaft 4703fd574f Merge pull request #468 from the-luap/fix/activity-log-feature-flags-and-missing-types
fix(activity-log): smart feature_flags_updated rendering + 33 missing activity types
2026-05-11 22:15:43 +02:00
Paul Nothaft 72c55a7625 Merge pull request #467 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.47.1-beta.0
2026-05-11 22:15:31 +02:00
Paul Nothaft fad2de5abe fix(activity-log): smart feature_flags_updated rendering + 33 missing types
The Dashboard "Recent Activity" widget and the header notifications
dropdown both rendered raw activity-type strings (e.g. the literal
"feature_flags_updated") for any type missing from their lookup
maps — including everything emitted by the recently-added customer
portal (#354), webhooks (#327), API tokens (#322), event types,
event-publish flow, admin user management (#350), and the
feature-flags reorg itself.

Two coordinated changes:

1. Smart formatter for feature_flags_updated. The backend writes
   `metadata.changed = { [flagKey]: { from, to } }` on every save.
   New formatFeatureFlagsChanged() helper in admin.service.ts reads
   that diff and renders:
     - 1 change → "Customer Portal enabled"
     - N changes → "3 features updated: Customer Portal enabled,
       Calendar disabled, Quotes enabled"
   Per-flag display labels source from `settings.features.<key>.title`
   so they stay in sync with the Features tab. Unknown flag keys
   fall through to a humanised version of the key.

2. 33 missing activity types added to BOTH renderers and to the
   `admin.activities.*` + `admin.notificationMessages.*` i18n
   namespaces across all six locales. Coverage groups: customer
   portal (13 types), admin user management (6), webhooks (3),
   API tokens (2), event types (4), event publish/logo (3), bulk
   delete (1), and assorted post-merge surfaces (4).

   The notifications.service.ts switch + admin.service.ts fallback
   message map are still duplicated; consolidating them into a
   single source of truth is a follow-up worth doing before the
   next significant addition. For now both stay in sync via this PR.

en + de hand-translated. nl + pt + ru + fr machine-translated and
flagged for native review per project convention.
2026-05-11 22:06:10 +02:00
github-actions[bot] d9d52ec8ab chore(beta): release 3.47.1-beta.0 2026-05-11 19:57:31 +00:00
Paul Nothaft 441cc41937 Merge pull request #466 from the-luap/fix/features-tab-customer-portal-label
fix(features): customer-portal card uses 'Clients' to match sidebar wording
2026-05-11 21:56:57 +02:00
Paul Nothaft dec2f5d3d2 fix(features): customer-portal card uses 'Clients' to match sidebar wording
Settings → Features showed the customer-portal toggle as "Accounts"
("Konten" in DE, "Comptes" in FR, etc.) — the deeper sub-nav label
inside ClientsLayout — while the prominent menu-bar entry the admin
actually clicks first reads "Clients" / "Kunden". The mismatch was
confusing on first encounter ("which one do I look for?").

Align the Features tab card title and the "Sidebar:" callout with
the menu-bar wording (`navigation.clients`) across all six locales.
The sub-nav inside ClientsLayout keeps its own "Accounts" label —
that one matches the /admin/clients/accounts URL and is correct.
2026-05-11 21:52:42 +02:00
Paul Nothaft c86ee3d249 Merge pull request #465 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.47.0-beta.0
2026-05-11 21:52:28 +02:00
github-actions[bot] 1c23ec51ec chore(beta): release 3.47.0-beta.0 2026-05-11 19:44:45 +00:00
Paul Nothaft 4f3db923a6 Merge pull request #464 from Luca-Timo/feat/email-templates-reorg
Feat/email templates reorg
2026-05-11 21:44:17 +02:00
Luca 2343a162df fix(email-templates): backfill subcategory + customer password reset translations 2026-05-11 21:08:16 +02:00
Luca e3150e4213 feat(email-templates): seed missing locale translations + post-075 templates 2026-05-11 20:56:58 +02:00
Luca 53eecb6f83 feat(email-templates): group Templates UI by category with core sub-sections 2026-05-11 20:56:50 +02:00
Luca 2cae3fe47d feat(email-templates): categorise + sub-categorise + link to feature flags 2026-05-11 20:56:28 +02:00
Luca 358f7ee99e feat(email-templates): seed missing nl/pt/ru/fr translations 2026-05-11 20:42:28 +02:00
Luca 5ec26fc998 feat(email-templates): group Templates UI by category + Feature off chip 2026-05-11 20:42:16 +02:00
Luca 84c06affb7 feat(email-templates): categorise + link to feature flags 2026-05-11 20:41:50 +02:00
Paul Nothaft a125ea2ada Merge pull request #463 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.3-beta.0
2026-05-11 20:19:43 +02:00
github-actions[bot] b31fc2140b chore(beta): release 3.46.3-beta.0 2026-05-11 18:18:34 +00:00
Paul Nothaft bd2288e6a0 Merge pull request #461 from the-luap/fix/branding-socials-not-loaded
fix(branding): socials + promo round-trip from DB to form (#460)
2026-05-11 20:18:09 +02:00
Paul Nothaft ae64a6acbc fix(branding): socials + promo round-trip from DB to form (#460)
formatBrandingSettings was updated when the BrandingSettings
interface added the footer-overhaul fields (#441 / #440), so the
admin BrandingPage initialised them as empty strings on every load.
Saving any other field then sent the form's empty socials /
promo_markdown / promo_position back to the backend and wiped the
saved values from the DB. The public gallery footer kept rendering
the old values until the next save, which is why the bug appeared
asymmetric (visible to galleries, gone from the admin form).

Add the missing read mappings for the seven branding_* keys so the
form round-trips them correctly.

Reported by @Rekoo-PS in #460 (split out of #447).
2026-05-11 20:11:19 +02:00
Paul Nothaft 4e0d26d3f8 Merge pull request #462 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.2-beta.0
2026-05-11 20:09:57 +02:00
github-actions[bot] eddcdbf4d8 chore(beta): release 3.46.2-beta.0 2026-05-11 18:07:41 +00:00
Paul Nothaft 9776d8a6fc Merge pull request #458 from Luca-Timo/fix/customer-functions
fix(customer-portal): post-merge fixes for event save, theme fonts, and customer→gallery handoff
2026-05-11 20:07:11 +02:00
Luca c0c6b4c0e8 chore(customer-portal): align flag-gate comments with new dual-enforcement 2026-05-11 19:38:18 +02:00
Luca 2a7ae0702d fix(events): CustomerAccountPicker hooks order crashed /admin/events/new 2026-05-11 19:37:52 +02:00
Paul Nothaft 06733f841a Merge pull request #457 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.1-beta.0
2026-05-11 19:08:13 +02:00
Luca 8d9d0bea83 fix(customer): customer sidebar active state matches admin pattern 2026-05-11 16:48:29 +02:00
Luca eb0c45e0f4 chore(i18n): drop dead navigation.customers keys 2026-05-11 16:39:30 +02:00
Luca 9091ed4012 feat(clients): scaffold top-level Clients section with sub-nav around Accounts 2026-05-11 16:17:14 +02:00
Luca 35f5b86d0f fix(theme): 'Same as body' heading font no longer inherits stale value 2026-05-11 11:50:09 +02:00
Luca 7ac1d14738 fix(customer): preserve slug-scoped gallery tokens on auth provider mount 2026-05-11 11:32:59 +02:00
Luca bf7ef14626 fix(settings): readable contrast on accent-tinted icon tiles + pills 2026-05-11 11:27:16 +02:00
Luca 2f00bbdd90 fix(settings): neutralize sidebar icons for a consistent palette 2026-05-11 11:11:23 +02:00
Luca 15d01f3756 fix(features-tab): icon tiles + preview pills follow CI accent 2026-05-11 11:07:55 +02:00
Luca 75e41eba03 feat(branding): toggle login-page logo frame + size 2026-05-11 11:02:33 +02:00
Luca dde72a1b1b fix(events): strip customer_account_ids from update spread 2026-05-11 10:53:12 +02:00
github-actions[bot] 3ea7eea4b7 chore(beta): release 3.46.1-beta.0 2026-05-11 08:07:45 +00:00
Paul Nothaft 5b148542e6 Merge pull request #455 from the-luap/fix/photo-dimensions-and-default-fit
fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
2026-05-11 10:07:07 +02:00
Paul Nothaft 88a865c813 Merge pull request #456 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.46.0-beta.0
2026-05-11 10:06:41 +02:00
Paul Nothaft 2f63188a34 fix(events): TDZ ReferenceError on /admin/events from #442 fix (#454)
The pagination-clamp useEffect added in #448 (commit 9c4a96f) was
inserted at the top of the component body, BEFORE the useQuery that
declares `data`. Because the useEffect's dependency array
`[data?.pagination, page]` is evaluated immediately when that line
executes, every render hit a temporal dead zone access on `data` and
threw `ReferenceError: Cannot access 'data' before initialization`
— minified to "Cannot access 'I' before initialization" in the
production bundle, crashing the entire page.

TypeScript caught this at the time
(`Block-scoped variable 'data' used before its declaration`) but the
project's build doesn't fail on TS errors so it shipped anyway.

Move the effect to immediately after the useQuery so `data` is in
scope. Behavior unchanged otherwise — same dep array, same setPage
clamp logic.

Reported by @derooijmnl on v3.45.1-beta.0.
2026-05-11 10:03:40 +02:00
Paul Nothaft 49b36a0352 chore(migrations): renumber 090 → 096 + small notes from #403 review
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.
2026-05-11 09:59:47 +02:00
Paul Nothaft 936a277eb8 fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
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.
2026-05-11 09:58:08 +02:00
github-actions[bot] 87dfae0074 chore(beta): release 3.46.0-beta.0 2026-05-11 07:57:50 +00:00
Paul Nothaft fe5295373b Merge pull request #403 from Luca-Timo/feat/user-accounts
feat: customer accounts (#354) — recurring logins, profile, password reset, branded customer surface
2026-05-11 09:57:15 +02:00
Luca 032a43ad01 i18n(customers): add nl / pt / ru / fr translations for customer portal
Previously these locales fell through to en for every customer.* /
customers.* / settings.customerSurface / settings.features.customerPortal
key. Machine-translated and flagged in the PR description as needing
native review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 02:13:08 +02:00
Luca fd46c171ce chore(branding): move Customer dashboard card between Company Info and Gallery Theme
Keeps the customer-surface branding toggles adjacent to the other
brand-visibility controls instead of floating at the bottom of the
page, where they were easy to miss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:59:30 +02:00
Luca b252cb67eb feat(branding): Customer dashboard header toggles in Branding page
Adds back the "Show logo" / "Show company name" toggles for the
customer dashboard, scoped to /customer/* surfaces only. Lives as a
dedicated card at the bottom of Settings → Branding, gated by the
customerPortal feature flag so admins who haven't enabled the portal
don't see it.

* Backend: restored GET/PUT /admin/settings/customer-surface
  endpoints, whitelisted only to the two branding keys
  (customer_show_logo, customer_show_company_name). The
  calendar/quotes/bills feature globals that used to live on this
  endpoint are now driven by the Features tab (feature_flags table).
* customerAccountsService.getCustomerSurfaceGlobals() reads from
  app_settings again so /api/customer/auth/session honours the
  toggles in its branding payload.
* New CustomerDashboardBrandingCard component with its own save
  flow — separate from the main BrandingPage payload so flipping a
  toggle doesn't replay the full branding mutation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:52:27 +02:00
Luca da08a5828a fix(customer): unwrap /customer/* from RequireFeature gate
RequireFeature calls useFeatureFlags(), which throws unless mounted
inside FeatureFlagsProvider — and that provider only wraps
AdminLayout. So unauthenticated visitors hitting /customer/login
crashed into the React error boundary with 'Oops! Something went
wrong'.

The customerPortal flag continues to hide every admin-side surface
(sidebar entry, /admin/customers routes, CustomerAccountPicker on
event forms), which is what the flag is actually for. The
customer-side tree stays reachable so existing customers can still
log in even if the admin flips the flag off temporarily.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:30:19 +02:00
Luca f048011324 fix(server): mount /api/admin/feature-flags route
The route was registered in upstream/beta's server.js but dropped
during the rebase squash — the Features tab GET/PUT both 404'd, so
the customerPortal flag (and every other flag) couldn't be toggled.
Restored the mount in its upstream/beta position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:13:17 +02:00
Luca 4fa7225732 fix(server): drop missing requireCustomerPortal middleware import
server.js was still requiring ./src/middleware/requireCustomerPortal
— a file deleted during the AdvancedFeaturesTab cleanup — which
crashed the backend on boot in production (MODULE_NOT_FOUND).

The customerPortal feature flag is now enforced on the frontend via
<RequireFeature flag="customerPortal" /> route guards (App.tsx) and
AdminSidebar visibility. Defence in depth is provided by
customerAccountsService.isCustomerPortalEnabled() in adminEvents.
Routes themselves are still protected by adminAuth / customerAuth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:48:13 +02:00
Luca adfa29e91e fix(auth): restore COOKIE_SECURE='auto' default for production
The customer-portal squash inadvertently reverted the upstream/beta
fix from PR #427: production NODE_ENV was flipping the cookie Secure
flag back to hard `true`, which broke admin login on
HTTPS-frontend → HTTP-backend reverse-proxy stacks (browser drops
the Secure cookie over HTTP, login loops indefinitely).

Restored upstream/beta's tokenUtils.js verbatim and re-layered only
the customer cookie helpers (CUSTOMER_COOKIE_NAME,
setCustomerAuthCookie, clearCustomerAuthCookie,
getCustomerTokenFromRequest) on top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:29:04 +02:00
Luca 087ef45942 feat(customers): customer portal (#354) on top of feature-flags reorg
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 <noreply@anthropic.com>
2026-05-11 00:05:20 +02:00
Paul Nothaft f2f48f31b0 Merge pull request #453 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.45.1-beta.0
2026-05-10 22:09:48 +02:00
github-actions[bot] 55e6395e9e chore(beta): release 3.45.1-beta.0 2026-05-10 20:09:09 +00:00
Paul Nothaft 37d487db86 Merge pull request #452 from the-luap/fix/create-event-branding-default-race
fix(create-event): branding-default theme survives eventTypes refetch (#323-B)
2026-05-10 22:08:42 +02:00
Paul Nothaft d62c529b02 fix(create-event): branding-default theme survives eventTypes refetch
The "apply recommended preset on event-type change" effect was firing on
the initial mount AND every time the eventTypes API resolved (because
availableEventTypes is recomputed when that query settles). The first
fire matched the wedding default and clobbered the global Branding
theme that the previous effect had just applied.

Track the previous event_type in a ref and bail out when it hasn't
actually changed. The Branding-default effect now wins on first paint,
and the recommended-preset behaviour still kicks in when the user
manually picks a different event type.

Restores the green state of smoke spec 07 (#323-B regression).
2026-05-10 22:02:53 +02:00
Paul Nothaft 6ddb8e34d2 Merge pull request #451 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.45.0-beta.0
2026-05-10 21:58:21 +02:00
github-actions[bot] 421354bc5a chore(beta): release 3.45.0-beta.0 2026-05-10 19:57:54 +00:00
Paul Nothaft f3505c2631 Merge pull request #450 from the-luap/feat/footer-overhaul-441-440
feat(footer): hideable legal links + socials + promo banner (#441 + #440)
2026-05-10 21:57:35 +02:00
Paul Nothaft be207d7120 Merge pull request #449 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.44.2-beta.0
2026-05-10 21:54:23 +02:00
github-actions[bot] d2864854e7 chore(beta): release 3.44.2-beta.0 2026-05-10 19:51:03 +00:00
Paul Nothaft b4e30a4293 Merge pull request #448 from the-luap/fix/issue-442-bulk-delete-pagination
fix(events): clamp page state when totalPages drops below current page (#442)
2026-05-10 21:50:36 +02:00
Paul Nothaft 3a731e7c95 feat(footer): hideable legal links + socials + promo banner (#441 + #440)
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.
2026-05-10 21:46:20 +02:00
Paul Nothaft 9c4a96fe97 fix(events): clamp page state when totalPages drops below current page (#442)
Bulk-deleting all events on the current page left the list empty until
manual reload. After the React Query refetch returned `events: []` with
a smaller `totalPages`, the page state was stuck on the old (now
out-of-range) page index — the backend correctly serves an empty page
for `page > totalPages`, but the UI had no logic to step back.

Add a useEffect that watches `data.pagination.totalPages` against the
current `page` and resets `page = max(1, totalPages)` whenever the
result count shrinks. Fires after every refetch so it covers bulk
delete, individual delete, archive, and any filter change that
shrinks the result set — same one-line guarantee.

Reported by @Rekoo-PS in #442.
2026-05-10 21:18:10 +02:00
Paul Nothaft c52c1c8419 Merge pull request #446 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.44.1-beta.0
2026-05-10 21:02:45 +02:00
github-actions[bot] 481f8c2f9f chore(beta): release 3.44.1-beta.0 2026-05-10 19:01:51 +00:00
Paul Nothaft 3fd8af3d56 Merge pull request #445 from the-luap/fix/issue-426-edit-allows-clearing-expiry
fix(events): admins can clear expiration on edit even when 'Require expiration' is ON (#426)
2026-05-10 21:01:28 +02:00
Paul Nothaft e54456135c fix(events): admins can clear expiration on edit even when "Require expiration" is ON (#426)
iSchumi6210 reported that with the global "Require expiration date"
toggle ON, an admin couldn't clear the expiration on an existing event
via the Edit Event form. The PUT returned 400 "Expiration date is
required."

The cause was intentional in the original code: the global setting was
enforced on both create AND edit, so once flipped ON, no event could
ever be cleared of its expiration — not even by admins editing one-by-
one. Reproduced the exact scenario byte-for-byte against beta:

  Toggle ON → POST /admin/events {expiration_days: 30} → 200 created
  Toggle ON → PUT /admin/events/:id {expires_at: null} → 400 rejected

The setting now controls only the create-time default. On edit, an
admin can clear the field and the value persists as NULL ("never
expires"). Matches CMS-style admin tool conventions where field-
required-by-default doesn't lock the field after creation.

Backend: drop the `getEventFieldRequirements()` enforcement on the
expires_at branch in PUT /admin/events/:id. Empty/null on edit
normalizes to NULL.

Frontend: drop the matching `requireExpiration && !editForm.expires_at`
toast in EventDetailsPage. The variable is no longer referenced, so
remove its declaration too.

Verified end-to-end with toggle ON:
  STEP 1: create with expiration → ok (unchanged)
  STEP 2: create without expiration → backend auto-applies default 30d
          (create-time enforcement intact)
  STEP 3: PUT {expires_at: null} on existing → "Event updated
          successfully" (was 400)
  STEP 4: DB column expires_at is NULL
  STEP 5: PUT {expires_at: ''} also accepted (matches what an HTML
          date input sends when cleared)

Smoke 13/13 green; no regressions.
2026-05-10 20:54:36 +02:00
Paul Nothaft f1866e39c6 Merge pull request #444 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.44.0-beta.0
2026-05-10 20:49:16 +02:00
github-actions[bot] f6be07ed4b chore(beta): release 3.44.0-beta.0 2026-05-10 18:47:55 +00:00
Paul Nothaft c3798e19c8 Merge pull request #443 from the-luap/feat/feature-flags-settings-reorg
feat(settings): Features tab + sidebar reorg with feature-flag gating
2026-05-10 20:47:30 +02:00
Paul Nothaft 15e333681f feat(settings): Features tab + sidebar reorg with feature-flag gating
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.
2026-05-10 20:36:32 +02:00
Paul Nothaft b6aaea21ca Merge pull request #439 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.3-beta.0
2026-05-09 21:01:30 +02:00
github-actions[bot] fab0fcdde3 chore(beta): release 3.43.3-beta.0 2026-05-09 19:00:52 +00:00
Paul Nothaft d3007b0dd2 Merge pull request #438 from the-luap/fix/gallery-s3-serving-432
fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
2026-05-09 21:00:31 +02:00
Paul Nothaft 40d4c96998 Merge pull request #437 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.2-beta.0
2026-05-09 20:58:14 +02:00
Paul Nothaft 83d79f4d39 fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.

The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.

Changes:

- Add getRange(relPath, start, end) to the StorageBackend interface +
  LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
  (downloadStream with Range header). Needed for video range requests
  on S3 — previously the photo route did fs.createReadStream(filePath,
  {start, end}) which is local-only.

- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
  via storage.get. Watermark application path materializes the source
  via withLocalCopy (no-op in local mode, downloads to a tmp file then
  cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
  works.

- /:slug/photo/:photoId — branches on source_origin: external/reference
  photos still use the local fs path (NAS mounts are local), managed
  photos use the storage abstraction. Video range requests pass through
  to storage.getRange. Pre-generated watermarks served via storage too.
  On-the-fly watermark generation uses withLocalCopy for managed photos.

- /:slug/hero/:photoId — hero images are always managed-storage keys
  (imageProcessor.generateHeroImage writes via the storage abstraction),
  so this just switches to storage.stat + storage.get. Watermark via
  withLocalCopy.

Verified end-to-end against minio in dev:
  POST /api/admin/photos/N/upload         → photo + thumbnail land in S3
  GET /api/gallery/<slug>/thumbnail/<id>  → 200, JPEG 300x300 ✓
  GET /api/gallery/<slug>/photo/<id>      → 200, JPEG 1200x800 ✓
  GET /api/gallery/<slug>/hero/<id>       → 200, JPEG 1920x1080 ✓
  ETag round-trip (If-None-Match)         → 304 ✓
  Backend logs                            → no errors

LocalFs regression: 13/13 smoke tests pass.

Closes #432.
2026-05-09 20:56:20 +02:00
github-actions[bot] 197524a918 chore(beta): release 3.43.2-beta.0 2026-05-09 18:36:38 +00:00
Paul Nothaft ed37caf3d8 Merge pull request #434 from PiR1/doc/update-contributing
docs(contributing): update branch reference from main to beta
2026-05-09 20:36:20 +02:00
Paul Nothaft 02bbc5c30d Merge pull request #436 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.1-beta.0
2026-05-09 20:35:01 +02:00
github-actions[bot] 9ca9563dbe chore(beta): release 3.43.1-beta.0 2026-05-09 18:34:26 +00:00
Paul Nothaft b314bb21d3 Merge pull request #428 from PiR1/fix/update-event-access
Fix/update event access
2026-05-09 20:33:59 +02:00
PiR1 916580adef fix(event): ensure client share token is generated only when necessary 2026-05-09 20:28:16 +02:00
PiR1 479f16085b chore(changelog): update unreleased section with event access fix 2026-05-09 20:28:16 +02:00
PiR1 d00f6fa7de fix(event): correct updating client access 2026-05-09 20:26:55 +02:00
Paul Nothaft 087d71e8a7 Merge pull request #435 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.43.0-beta.0
2026-05-09 20:23:00 +02:00
github-actions[bot] 967ade7a9a chore(beta): release 3.43.0-beta.0 2026-05-09 18:22:30 +00:00
Paul Nothaft de871b32cc Merge pull request #425 from PiR1/feat/improve-localization
Feat/improve localization
2026-05-09 20:22:04 +02:00
PiR1 c114749921 docs(contributing): update branch reference from main to beta 2026-05-09 20:00:25 +02:00
PiR1 46b99c6292 feat(localization): improve English translations for clarity and consistency 2026-05-09 19:13:45 +02:00
PiR1 2c1288583f feat(localization): add French translations for fit options in thumbnails 2026-05-09 19:03:30 +02:00
PiR1 5fc427c74b feat(localization): update thumbnail settings and add fit options translations 2026-05-09 18:42:15 +02:00
PiR1 d1bc5e030f docs(localization): enhance French language support and improve i18next configuration 2026-05-09 18:23:12 +02:00
PiR1 e7228b0780 feat(localization): add i18next extraction helper & refactor backup configuration component to tsx 2026-05-09 18:23:12 +02:00
PiR1 86ee6c80aa feat(localization): add missing translations 2026-05-09 18:23:11 +02:00
PiR1 74e87b968b feat(localization): add i18next configuration and CLI commands for localization management 2026-05-09 18:23:11 +02:00
PiR1 a5db4bd46e feat(translations): add French language support and improve localization handling 2026-05-09 18:23:11 +02:00
Paul Nothaft fd98a78123 Merge pull request #431 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.7-beta.0
2026-05-09 16:03:38 +02:00
github-actions[bot] 72a7a43a4a chore(beta): release 3.42.7-beta.0 2026-05-09 14:00:44 +00:00
Paul Nothaft e1c93823c4 Merge pull request #429 from the-luap/fix/cookie-secure-auto-default-427
fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
2026-05-09 16:00:19 +02:00
Paul Nothaft 63c1b028bf Merge pull request #430 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.6-beta.0
2026-05-09 16:00:02 +02:00
github-actions[bot] 715a806991 chore(beta): release 3.42.6-beta.0 2026-05-09 13:59:36 +00:00
Paul Nothaft e2ffd9f93d Merge pull request #424 from the-luap/fix/external-thumbnails-423
fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
2026-05-09 15:59:17 +02:00
Paul Nothaft 5c7de96b7f fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
Two intertwined bugs reported in #427 by @iSchumi6210:

1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true
   when NODE_ENV=production. Over plain HTTP the browser drops the Secure
   cookie → next /auth/session request returns 401 → redirect back to
   /admin/login → no error shown. picpeak-setup.sh writes
   NODE_ENV=production but never writes COOKIE_SECURE, so every first-time
   install without a reverse proxy hits this.

2. Admin password is generated but admins can't find it. The 001_init.js
   migration writes the generated password to data/ADMIN_CREDENTIALS.txt
   inside the backend container, but picpeak-setup.sh only copies it out
   when --reset-admin-password is passed. Default-path users never see it
   and resort to manual bcrypt updates in psql.

Changes:

- tokenUtils.js: production default goes from `true` to `'auto'`. On real
  HTTPS req.secure is true → Secure flag is still emitted (no security
  regression for reverse-proxy deployments). On plain HTTP req.secure is
  false → Secure flag omitted → login works. Users who explicitly want
  the strict HTTPS-only behaviour can still set COOKIE_SECURE=true.

- .env.example: rewrite the COOKIE_SECURE block to make the new default
  obvious and explain when to override (set =true for strict, =false to
  skip the per-request check, leave unset for the auto behaviour).

- picpeak-setup.sh (both Docker and native paths):
  - Write COOKIE_SECURE=auto explicitly to the generated .env (defense in
    depth so the right behaviour is preserved even if the backend default
    flips again later)
  - After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the
    backend container/data dir to the host data dir, chmod 600, and print
    the email + password to the install output. The credentials file
    remains as a backup record that the operator should delete after
    noting the password.

Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE:

  production, unset      → HTTPS: secure=true ✓  HTTP: secure=false ✓ (was both true)
  production, =true      → both: secure=true (strict opt-in preserved)
  production, =auto      → HTTPS: secure=true   HTTP: secure=false (already-correct)
  development, unset     → both: secure=false (dev unchanged)
2026-05-09 15:55:09 +02:00
Paul Nothaft f3d0f161c9 fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
External (source_origin='external') photos always had thumbnail_path=NULL,
so the gallery returned thumbnail_url=null and every layout fell back to
streaming the full original from the NAS via the secure-image route.
With ~100 NAS-mounted photos that meant minutes of wall-clock load time,
sequential per tile.

Two halves:

1. import-external route generates the thumbnail right after each
   successful insert and writes thumbnail_path on the row. Best-effort:
   a single failure logs a warning and leaves thumbnail_path=NULL —
   ensureThumbnail will retry lazily on first view. Synchronous in the
   loop adds ~100-300ms per image; for the worst-case 1000-photo import
   that's still under the typical request timeout.

2. ensureThumbnail() in imageProcessor handles external photos too —
   resolves the local NAS mount path via resolvePhotoFilePath instead of
   the storage-backend key. This covers existing externals already in
   the database that were imported before this fix: first gallery view
   per photo regenerates the thumbnail, subsequent views are fast.

Filename-collision protection: external thumbnails use
`thumb_ext<photoId>_<basename>` so two events both referencing
e.g. `IMG_0001.jpg` on different NAS subtrees can't clobber each other's
thumbnail. generateThumbnail accepts a new options.outputBasename to
support this without changing the managed-photo behaviour.

Verified locally with a 3-photo external dir and a real NAS-style import:
  POST /api/admin/external-media/events/N/import-external
  → {imported:3, thumbnailsGenerated:3, thumbnailsFailed:0}
  /api/gallery/<slug>/photos returns thumbnail_url for every photo
  Lazy-regen path: clearing thumbnail_path + deleting the file, then
  hitting /thumbnail/N regenerates and repopulates the row in 42ms.

Closes #423.
2026-05-08 19:17:15 +02:00
Paul Nothaft f6cf470291 Merge pull request #422 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.5-beta.0
2026-05-08 10:16:11 +02:00
github-actions[bot] 08707eee9e chore(beta): release 3.42.5-beta.0 2026-05-08 08:15:24 +00:00
Paul Nothaft 9326a427b3 Merge pull request #420 from the-luap/fix/update-notification-test-email-418
fix(admin): test email always sends, regardless of update availability (#418)
2026-05-08 10:15:01 +02:00
Paul Nothaft e54401930d Merge pull request #421 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.4-beta.0
2026-05-08 10:14:48 +02:00
github-actions[bot] 9ade631e13 chore(beta): release 3.42.4-beta.0 2026-05-08 08:14:16 +00:00
Paul Nothaft e165ee5d9f Merge pull request #419 from the-luap/fix/bulk-delete-typed-confirm-417
fix(events): typed-DELETE confirmation for bulk delete (#417)
2026-05-08 10:13:52 +02:00
Paul Nothaft c2b1854df6 fix(admin): test email always sends, regardless of update availability (#418)
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"}.
2026-05-08 09:57:31 +02:00
Paul Nothaft 99e420b1b9 fix(events): typed-DELETE confirmation for bulk delete (#417)
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).

Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.

Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.

i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.

Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
2026-05-08 09:46:24 +02:00
Paul Nothaft f57429faf2 Merge pull request #415 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.3-beta.0
2026-05-07 23:05:40 +02:00
github-actions[bot] e8df3de3fb chore(beta): release 3.42.3-beta.0 2026-05-07 21:04:20 +00:00
Paul Nothaft 7abfeb91cc Merge pull request #414 from the-luap/security/scan-cleanup-2026-05-08
fix(security): scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage
2026-05-07 23:03:48 +02:00
Paul Nothaft 401abf7a27 fix(create-event): re-apply Branding theme on stale→fresh settings (#323-B)
CreateEventPage's branding-default effect used a boolean ref guard that
locked in whichever theme_config arrived first. React Query can hand the
observer a cached (stale) copy on initial render and then push fresh data
once the network call resolves — the boolean ref meant the form kept the
stale theme and ignored the fresh one.

Replace the ref with a stringified-hash check: re-apply when the source
actually changes (including stale → fresh) but skip when nothing has.
User edits via the customizer aren't disturbed because settings.theme_config
only refreshes on a real Branding save, not on form state.

This unblocks the local pre-push smoke gate's 07-branding-default test,
which was test.fixme'd against this exact React Query staleness.
2026-05-07 22:42:16 +02:00
Paul Nothaft 6b6191a426 fix(security): scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage
Triage of an external SAST/SCA scan run on 2026-05-06. Most loud findings
were already resolved by PR #412 (the 18-CVE backport); this PR addresses
the residual real items:

* Drop unused `handlebars` from backend deps. The runtime require was
  removed in PR #367 (#367) but the package.json line stayed. handlebars
  was the source of two flagged criticals (CVE-2026-33937 RCE,
  GHSA-2w6w-674q-4c4q AST injection) plus 8 highs — all now gone.

* `npm audit fix` on backend + frontend. Bumps transitive picomatch,
  flatted, postcss, brace-expansion via lockfile, and direct dompurify,
  lodash, vite, i18next-http-backend within their existing semver ranges.
  Both audits now report 0 vulnerabilities.

* Add `event.origin === window.location.origin` check to the THEME_PREVIEW
  message listener in PreviewPage. The branding page posts from the same
  origin, so nothing legitimate is rejected; without the check, any third
  party that window.open()'d the preview could push arbitrary
  branding/theme payloads (semgrep
  insufficient-postmessage-origin-validation).

* nginx: `proxy_hide_header` for X-Frame-Options, X-Content-Type-Options,
  Referrer-Policy, Content-Security-Policy, Permissions-Policy,
  Strict-Transport-Security at server level. nginx adds these itself, but
  helmet on the backend was also emitting them — clients were seeing
  duplicates (testssl flagged "Multiple X-Frame-Options / CSP /
  Permissions-Policy / Referrer-Policy headers" on the live origin).
  Single source of truth now.

* Dockerfile hardening (checkov):
  - HEALTHCHECK on backend/Dockerfile, backend/Dockerfile.dev,
    frontend/Dockerfile.dev. Frontend production Dockerfile already had
    one.
  - USER node in frontend/Dockerfile.dev (was running as root).

* GitHub Actions docker-build.yml: explicit top-level
  `permissions: contents: read`. Per-job blocks already declare
  `packages: write` where needed; this stops future steps from
  inheriting unintended privileges (CKV2_GHA_1).

Backend npm audit: 4 vulns -> 0.
Frontend npm audit: 6 vulns -> 0.
Backend unit tests: 13 suites, 131/132 passing (1 pre-existing skip).
Frontend type-check + lint: clean.

The pre-existing integration-test failures (live DB / S3 required) and
the ThemeCustomizerEnhanced QueryClientProvider failures are unrelated
and reproduce on origin/beta without these changes.
2026-05-07 22:31:51 +02:00
Paul Nothaft dfe1023161 Merge pull request #411 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.2-beta.0
2026-05-07 14:14:26 +02:00
github-actions[bot] 8ab6fc6bbd chore(beta): release 3.42.2-beta.0 2026-05-07 12:14:07 +00:00
Paul Nothaft 523f49916b Merge pull request #409 from the-luap/fix/security-deps-2026-05
fix(security): patch 18 dependency CVEs (axios + transitives)
2026-05-07 14:13:40 +02:00
Paul Nothaft b7d6ca0b65 fix(security): patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend)
Closes the open Trivy code-scanning alerts for app-side dependencies.
The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch,
brace-expansion, ip-address inside the Node image itself) are deferred
to a separate Node-base-image PR — they're build-environment-side and
need their own compatibility testing.

## Direct dependency bumps

| Package | From | To | CVEs cleared |
|---|---|---|---|
| axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 |
| nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 |
| i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 |
| uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 |
| postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 |

## Transitive bumps (npm overrides)

For transitives whose direct parents haven't released a version that
picks up the patched range, pinned via npm overrides:

| Package | Min | CVE |
|---|---|---|
| follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 |
| fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 |
| @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 |
| ip-address (backend) | >=10.1.1 | CVE-2026-42338 |

## Why axios is now safe to bump past 1.14.0

PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain
attack on a specific compromised version range. The 1.15.x series
are post-incident upstream releases — clean. Confirmed with the
maintainer before bumping.

## Verified

* `npx tsc --noEmit` (frontend) — clean
* `npx vite build` (frontend) — clean (~4s, existing bundle-size
  warning, not new)
* Backend module-load smoke test — all critical modules load
  (`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`,
  `storage`) with the new axios + nodemailer
* Lockfile re-verification — every targeted CVE now resolves to
  the patched version range

## Remaining out of scope

* npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` —
  picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion
  CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These
  live in the Node base image and require a Node base image bump
  with its own compatibility testing — separate PR.

Targeting `beta` so the bumps go through the normal release-please
flow before promotion to `main`.
2026-05-07 13:51:55 +02:00
Paul Nothaft 25e6c8034c Merge pull request #406 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.1-beta.0
2026-05-07 12:37:28 +02:00
github-actions[bot] ba405c1dd2 chore(beta): release 3.42.1-beta.0 2026-05-07 10:36:15 +00:00
Paul Nothaft 04e928d762 Merge pull request #405 from the-luap/fix/gallery-download-cta-followup
fix(gallery): WCAG-safe Download button text + extract HeaderDownloadButton (#401 follow-ups)
2026-05-07 12:35:46 +02:00
Paul Nothaft 0c80abd57b fix(gallery): WCAG-safe Download button text + extract HeaderDownloadButton (#401 follow-ups)
Two follow-ups from PR #401's review:

1. Download button text was hardcoded `color: '#ffffff'`. Once admins
   start picking palettes via #400's expanded customizer, a pale accent
   (yellow, pastel blue, etc.) leaves the button unreadable — white
   text on near-white background.

   Fix: derive the foreground colour from the accent's WCAG relative
   luminance and expose it as the new `--color-accent-fg` CSS variable
   in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black
   text; dark backgrounds get white. Same treatment applied to
   `--color-accent-dark-fg` for the filled-CTA token.

   The Download button now reads `var(--color-accent-fg, #ffffff)` so
   any future component that paints on accent gets the same treatment
   for free, and legacy deployments before the variable is set fall
   back to the previous hardcoded white.

   Threshold-based (rather than "highest contrast ratio") to preserve
   how saturated mid-tone accents have always rendered. The Picpeak
   default green (#5C8762, L≈0.20) keeps white text — same visual
   identity as before. Only genuinely pale accents flip to black,
   which is the actual scenario the review flagged.

2. The Download button JSX was duplicated three times in
   GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines
   each). Extracted into a small inline `HeaderDownloadButton`
   component above the GalleryLayout export. Three call sites now
   collapse to a 5-line component invocation each. Markup,
   accessibility, and styling live in one place — future tweaks
   only need to happen once.

## Files

- `frontend/src/utils/contrast.ts` — new helper module:
  `relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and
  `getReadableForeground(hex)` (white-or-black picker).
- `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases:
  fallbacks, saturated mid-tones, pale accents, near-black,
  shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors
  (black/white luminance).
- `frontend/src/contexts/ThemeContext.tsx` — wire the helper into
  `applyTheme`: set `--color-accent-fg` from `accentColor` and
  `--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`.
- `frontend/src/components/gallery/GalleryLayout.tsx` — extract
  `HeaderDownloadButton` component above `GalleryLayout`, replace
  three inline button blocks with the component, update its inline
  style to read `--color-accent-fg` (with the legacy `#ffffff` as
  the CSS-variable fallback).

## Verified

- `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass
- `npx tsc --noEmit` — clean
- `npx eslint` clean on every touched file
- Default PicPeak green still renders white text (no regression)
- Pale accent (#fef9c3 yellow-100) now correctly renders black text
2026-05-07 11:42:25 +02:00
Paul Nothaft 183e3117b6 Merge pull request #404 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.42.0-beta.0
2026-05-07 11:35:02 +02:00
github-actions[bot] 9cf92f3e8e chore(beta): release 3.42.0-beta.0 2026-05-07 09:34:05 +00:00
Paul Nothaft 876b35b4a5 Merge pull request #401 from Luca-Timo/feat/gallery-header-cleanup
feat(gallery): icon-only menu, accent Download CTA (#386)
2026-05-07 11:33:34 +02:00
Paul Nothaft 8c69e950c6 Merge pull request #402 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.41.0-beta.0
2026-05-06 20:26:59 +02:00
github-actions[bot] 4a5395b3ac chore(beta): release 3.41.0-beta.0 2026-05-06 18:26:41 +00:00
Paul Nothaft 8050927607 Merge pull request #400 from Luca-Timo/feat/darkmode-color-improvement
feat(branding): 8-token CI palette + force color mode + dark-mode consistency
2026-05-06 20:26:09 +02:00
Paul Nothaft 28154da64d Merge pull request #366 from filpgame/feat/add-pt-br
Add Brazilian Portuguese (pt-BR) translation
2026-05-06 20:05:05 +02:00
Luca de8ad5fdd5 feat(gallery): icon-only menu, accent Download CTA, logo aligned (#386)
Addresses the-luap/picpeak#386 — gallery header layout cleanup.

- Drop the redundant "Menu" text label; menu button is icon-only with
  tight padding (p-2).
- Absolute-position the menu icon at the very left of the header so it
  no longer pushes the logo right with every other action. Logo wrapper
  picks up pl-12 sm:pl-14 only when a menu button is rendered, so the
  icon and logo don't overlap. When no menu button (controlsStyle:
  classic), logo is flush with .container.
- New accent-coloured "Download" CTA placed immediately left of Logout.
  Always visible when downloads are allowed; replaces the previous
  primary-coloured "Download All" header button. Same CTA appears in
  standard, hero, and minimal headers. Intentionally NOT shown in the
  no-header variant (chromeless by design).
- Coloured via var(--color-accent) inline so the button automatically
  tracks whatever palette the admin has chosen — works on plain beta
  today (#22c55e) and auto-upgrades to the CI accent when #400 lands.

The sidebar's own Download All is untouched. Old showDownloadAll prop
stays on GalleryLayout for back-compat; GalleryView now passes
showDownloadAll={false} so only the new accent button renders in the
header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 13:01:09 +02:00
Luca 21188f48d7 fix(theme): centralise force-mode enforcement inside ThemeContext so every gallery flips 2026-05-06 02:27:05 +02:00
Luca a76ecf8496 chore(theme): remove LBM-specific preset (private to maintainer instance) 2026-05-06 02:22:25 +02:00
Luca bdbe7b80a1 feat(events): Sync from Branding button in gallery theme customizer + clarified default inheritance 2026-05-06 01:59:02 +02:00
Luca 47b6b39f3a feat(email): expand email palette to 8 tokens + Sync from Branding button 2026-05-06 01:41:45 +02:00
Luca 565ae45ca7 fix(admin): tab underlines use accent (not accent-dark) for proper highlight color 2026-05-06 01:29:54 +02:00
Luca 578a1745b8 fix(branding): comprehensive sweep — replace remaining primary-* legacy colors with accent tokens 2026-05-06 01:16:52 +02:00
Luca fc2bce3a01 fix(branding): admin sidebar uses accent-dark, primary buttons follow CI token 2026-05-06 00:57:54 +02:00
Luca b19bb0c620 fix(branding): working tooltips, high-contrast selected states, gallery chrome follows accent 2026-05-05 17:29:15 +02:00
Luca 5b410ed9f8 fix(branding): selected-state accent colors, force-mode actually flips galleries, compact color picker layout 2026-05-05 16:48:47 +02:00
Luca 67d7d8d3fa feat(branding): inline force color mode with auto-save + clearer palette help text 2026-05-05 16:29:00 +02:00
Luca d2a10f6523 fix(cms): apply dark mode to CMS editor, public CMS, and admin modals 2026-05-05 16:07:21 +02:00
Luca 5a162fc8be feat(branding): force color mode (dark or light) site-wide 2026-05-05 16:06:38 +02:00
Luca 114aab5777 feat(theme): expand color settings to 8-token CI palette + alt button 2026-05-05 16:04:43 +02:00
filpgame f25559c0e7 feat(i18n): improve pt locale with pt-BR phrasings, remove duplicate pt-BR file 2026-05-05 10:53:29 -03:00
filpgame 375f51285b feat(i18n): add Brazilian Portuguese (pt-BR) locale 2026-05-05 10:49:35 -03:00
Paul Nothaft ee7de6f6a1 Merge pull request #399 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.40.1-beta.0
2026-05-05 00:22:28 +02:00
github-actions[bot] 01f5e44353 chore(beta): release 3.40.1-beta.0 2026-05-04 22:19:57 +00:00
Paul Nothaft c8e09c2a2a Merge pull request #398 from the-luap/fix/auth-session-timeout-symmetry
fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)
2026-05-05 00:19:31 +02:00
Paul Nothaft b106da1ede fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)
Third loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns
after a server restart or after an idle gap longer than the configured
session timeout.

## Root cause (server)

`sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It
rejects with `401 SESSION_TIMEOUT` when either:
  - the in-memory `lastActivity` for the token is older than the timeout, or
  - this is the first request with this token AND the token's `iat` is
    older than the timeout (post-restart guard).

`/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`,
so the middleware never runs for it. Result: an idle/old-iat admin token
returns `valid: true` from `/auth/session` while every protected
endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's
401 interceptor hard-redirects to `/admin/login`, `/auth/session` says
valid again, loop closes — exact same shape as the previous two
asymmetries the symmetry pass missed.

Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to
`middleware/sessionTimeout.js` that reads the same in-memory map and
applies the same lastActivity / iat-vs-timeout logic as the middleware,
without updating the map (the middleware is the only place that records
activity; `/auth/session` is read-only by design). `/auth/session`
calls the helper for `decoded.type === 'admin'` after the existing
admin-existence and password-change checks. Same try/catch fall-through
pattern as the prior fixes so a missing/broken helper doesn't fail-closed
during early bootstrap or in test stubs.

## Root cause (client race amplifying the loop)

Even with the server fix, the previous `useSessionTimeout` hook called
`AdminAuthContext.logout()` which dispatches `POST /auth/logout`
fire-and-forget AND has its own `finally { window.location.href }`,
then immediately set `window.location.href = '/admin/login?session=expired'`
on top. Two consequences:
  - The cookie wasn't reliably cleared before the new page loaded —
    if any /auth/session asymmetry slipped through, the loop replayed
    inside the same tab. New-tab and "refresh several times" "fixes"
    were just the logout request eventually completing.
  - Two redirects raced; sometimes the `?session=expired` query was
    dropped, breaking the login-page toast.

Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie
is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly
instead of going through AdminAuthContext.logout (which has the
side-effect redirect we don't want), and (c) navigate exactly once
with the `?session=expired` query.

## Tests

- `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under
  a `session-timeout symmetry` describe block: helper says expired →
  valid:false; helper says active → valid:true; helper not called for
  gallery tokens; helper throws → fall through to valid:true (defensive).
  Existing 9 tests still pass (mock now includes
  `isSessionExpired: jest.fn(() => Promise.resolve(false))` as the
  default).
- `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7
  new unit tests for the helper itself: fresh token / old-iat /
  recently-active / null-input / no-mutation / 60-min default
  boundary cases.

20 cases total, all green. Lint clean on every touched file.
2026-05-05 00:12:01 +02:00
Paul Nothaft 045620e999 Merge pull request #396 from the-luap/fix/fonts-test-mock
test(fonts): fix mock bypass and case-insensitive FS skip (#390 follow-up)
2026-05-04 23:30:44 +02:00
Paul Nothaft dedb05ae28 Merge pull request #395 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.40.0-beta.0
2026-05-04 23:30:35 +02:00
Paul Nothaft 51f28d7330 test(fonts): fix mock bypass and case-insensitive FS skip (#390 follow-up)
Two issues in the fonts service test suite added by #390 — the behaviour
assertions all passed, but 5 of 24 tests had assertions that silently
no-op'd, so any regression in those code paths would not have been
caught.

## Issue 1: jest.resetModules() bypassed the logger mock

`beforeEach` called `jest.resetModules()` then re-required `fontsService`.
After resetModules, the `jest.mock('../../src/utils/logger', ...)` factory
at the top of the file no longer applied to subsequent requires — so the
freshly-required `fontsService` captured the REAL logger while the test
file's `logger` variable still pointed at the mocked one. The 4
"warning logged" / "info logged" assertions resolved as 0 calls and
silently passed-as-noop.

The resetModules call wasn't necessary in the first place — module-level
state in fontsService is just the cache, which clearFontsCache() already
resets. And both getBundledFontsRoot() and getUserFontsRoot() read
process.env at call-time, not at module load, so the env vars set in
beforeEach are picked up without needing a fresh require.

Fix: require fontsService once at module top (inside the jest.mock
hoisting scope) and drop resetModules + the per-test re-require.

## Issue 2: case-insensitive filesystem (macOS / Windows)

The "case-insensitive duplicate within the same root" test created
`Inter/` and `INTER/` to trigger the dedup warning. On a case-sensitive
FS (Linux ext4) both directory entries exist and the dedup branch fires;
on macOS APFS or Windows NTFS the second mkdir resolves to the same
folder as the first, so only one ever exists and the dedup is
unreachable from this test setup. Test failed on macOS dev, passed on
Linux CI.

Fix: probe at load time by creating a lowercase file and checking if
its uppercase variant resolves to the same inode, then conditionally
test.skip the affected test on case-insensitive hosts. Comment in the
test body explains why.

## Result

23 of 24 tests now pass on macOS; the case-sensitive-only test runs on
Linux CI. All previously-no-op'd assertions now exercise their code
paths.
2026-05-04 23:26:25 +02:00
github-actions[bot] 3d8fe2ec6e chore(beta): release 3.40.0-beta.0 2026-05-04 21:23:32 +00:00
Paul Nothaft d04bf28808 Merge pull request #390 from Luca-Timo/feat/self-hosted-fonts
feat(branding): self-hosted webfonts with filesystem scanner
2026-05-04 23:23:04 +02:00
Luca b609a7a88c test(fonts): unit-test scanner edge cases and meta.json handling 2026-05-04 22:29:57 +02:00
Luca bd0e052b1a docs(fonts): cache rollout, stale-list note, meta.json 2026-05-04 22:28:41 +02:00
Luca 5703fcb806 fix(fonts): drop immutable Cache-Control to allow font replacement rollout 2026-05-04 22:28:17 +02:00
Luca dcff451572 feat(branding): per-family generic fallback via meta.json 2026-05-04 22:27:52 +02:00
Paul Nothaft e382aed4d1 Merge pull request #394 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.39.1-beta.0
2026-05-04 22:18:29 +02:00
github-actions[bot] ac05230867 chore(beta): release 3.39.1-beta.0 2026-05-04 19:37:59 +00:00
Paul Nothaft c60ab74ae2 Merge pull request #393 from the-luap/docs/contributors
docs(readme): add Contributors section with @Luca-Timo and @Rekoo-PS
2026-05-04 21:37:37 +02:00
Paul Nothaft dbe0a3055b docs(readme): add Contributors section with @Luca-Timo and @Rekoo-PS
The Acknowledgments block had a generic "thanks to all contributors"
line but no actual recognition by name. Two people in particular have
moved the project meaningfully forward and should be called out:

- @Luca-Timo — code contributor across multi-arch Docker, the external-
  URL CMS toggle, folder tree picker, admin email picker, self-hosted
  webfonts, the gallery header/banner decoupling, and typed-API
  refactors. Consistent quality.

- @Rekoo-PS — bug reporter and feedback loop. Filed the issues that
  drove the login-loop fix, gallery loading skeleton, redirection
  cleanup, mobile lightbox overhaul, admin events search-counter fix,
  photo-count column, and bulk-delete workflow. Also a BuyMeACoffee
  supporter.

Closes the implicit recognition gap and sets up the section so future
contributors can be added with a one-line PR.
2026-05-04 21:35:27 +02:00
Paul Nothaft 99392c5888 Merge pull request #392 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.39.0-beta.0
2026-05-04 21:28:06 +02:00
github-actions[bot] 6fe1d8de9e chore(beta): release 3.39.0-beta.0 2026-05-04 19:16:41 +00:00
Paul Nothaft 1f1a856083 Merge pull request #385 from Luca-Timo/beta
feat(gallery): decouple header style from layout, add banner option
2026-05-04 21:16:13 +02:00
Paul Nothaft d0d283a13e Merge pull request #391 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.38.0-beta.0
2026-05-04 21:01:06 +02:00
github-actions[bot] 0a48646529 chore(beta): release 3.38.0-beta.0 2026-05-04 19:00:37 +00:00
Paul Nothaft 647aea21ae Merge pull request #389 from the-luap/feat/events-bulk-delete
feat(events): bulk delete with password confirmation (#384)
2026-05-04 21:00:06 +02:00
Paul Nothaft 7491eb21c4 Merge pull request #388 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.37.0-beta.0
2026-05-04 20:59:53 +02:00
Paul Nothaft 48d538f94f feat(events): bulk delete with password confirmation (#384)
Adds the bulk-delete half of #384 — admins can select multiple
events from the list and delete them in one batch, gated by
re-entering their password.

## Why password confirmation

Bulk delete is destructive and irreversible (cascades across 5 DB
tables and 3 filesystem paths per event). Re-entering the password
matches the pattern already used by /auth/admin/change-password and
makes accidental clicks much harder than a plain "type DELETE to
confirm" — the muscle-memory required to type your real password is
a stronger gate than typing a literal word.

## Changes

### Backend (adminEvents.js)

- Extracted the per-event cascade-delete logic into a module-private
  `deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id
  route now calls it instead of inlining 60 lines of cascade — same
  behaviour, no drift between the per-event and bulk paths.
- New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`.
  Permission: `events.delete`.
  - Validates `eventIds` array length (1–100) and that each id is an
    integer. The 100-cap keeps request time bounded; the per-event
    cascade touches DB + filesystem so 1000 events at once would risk
    timing out the request.
  - Verifies `password` against the calling admin's bcrypt hash via
    `bcrypt.compare()` (same as /auth/admin/change-password). Wrong
    password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no
    events are touched.
  - Loops via `deleteEventCascade`, returns
    `{ results: { successful, failed } }` with the same shape as
    /bulk-archive so the frontend can show partial-failure feedback.
  - Logs `bulk_delete_completed` activity with totals.

### Frontend

- `events.service.ts`: `bulkDeleteEvents(eventIds, password)`.
- New `BulkDeleteModal.tsx`. Red/destructive variant of the
  bulk-archive modal:
  - Lists the events to be deleted (so the admin can verify).
  - Password input with show/hide toggle, autofocus, Enter-to-submit.
  - Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD
    response without losing the modal state — admin can retry
    without re-typing the event list.
  - "Processing" state replaces the form with a spinner + "Deleting
    N events. This may take a few minutes — please don't close this
    window." (i18n) so admins know not to abandon the page during
    a slow operation.
- `EventsListPage.tsx`: "Delete Selected" button next to "Archive
  Selected" in the bulk-actions bar (red-styled to signal danger),
  bulkDeleteMutation that maps the 401 to the modal's inline error
  and any other failure to a generic toast.

### i18n

12 new keys under `events.bulkDelete.*` in all 5 locales
(en/de/nl/pt/ru): title, warning, password label/placeholder/help,
submit, processing, incorrectPassword, successAll, successPartial,
errorGeneric, plus `events.deleteSelected` for the button. Hand-
written for de; nl/pt/ru should get a native-speaker pass at some
point but read naturally.

### Verified

- `npx tsc --noEmit` clean
- `npx eslint` clean on every touched file (4 pre-existing errors in
  adminEvents.js for unused vars unrelated to this PR)
- All 5 locale JSON files parse cleanly
- `node -e "require('./src/routes/adminEvents')"` loads the module

Closes the bulk-delete half of #384. The Photos-column half lands
separately in PR #387.
2026-05-04 20:56:00 +02:00
github-actions[bot] 67999999d8 chore(beta): release 3.37.0-beta.0 2026-05-04 18:52:09 +00:00
Paul Nothaft d561db802b Merge pull request #387 from the-luap/feat/events-photos-column
feat(events): add Photos column to admin events list (#384)
2026-05-04 20:51:44 +02:00
Paul Nothaft ffb4318a1f feat(events): add Photos column to admin events list (#384)
The admin events table didn't surface how many photos each event
contained — admins had to click into the event to find out. The
backend already computes `photo_count` for every row in the
GET /admin/events list response (adminEvents.js:794-796), so this
is a frontend-only display change.

- Insert a "Photos" column between Date and Status — groups with
  the "what's in this event" info.
- Right-aligned, tabular-nums for clean numeric alignment in the
  column.
- Reuses the existing `events.photos` i18n key already shipped in
  all 5 locales for the EventDetailsPage tab list ("Fotos" / etc.) —
  no new translations needed.
- Updates the empty-state colSpan from 7 to 8.

Closes the column-add half of #384. The bulk-delete request from
the same issue lands separately.
2026-05-04 20:47:13 +02:00
Luca f410207b2d revert(branding): per-option font preview (defer to follow-up) 2026-05-04 20:39:00 +02:00
Luca e6c03e4b6e fix(nginx): proxy /fonts requests to backend 2026-05-04 20:07:13 +02:00
Luca b4f9b65f1d feat(branding): preview each font in its own face in the picker dropdown 2026-05-04 19:44:20 +02:00
Luca bac51fe69a feat(branding): self-hosted webfonts with filesystem scanner 2026-05-04 19:15:47 +02:00
Luca 894e151255 i18n(gallery): nl/ru wording polish for standard header style 2026-05-04 16:58:26 +02:00
Luca 05dadff493 fix(gallery): preserve sidebar controlsStyle on banner migration 2026-05-04 16:58:17 +02:00
Luca 91ad6bba99 style(theme-customizer): match header-style picker grid to layout picker (3 cols) 2026-05-04 15:48:12 +02:00
Luca 045e9ea486 fix(gallery): default controls to inline for every layout 2026-05-04 15:46:41 +02:00
Luca 24d727752c Merge pull request #1 from Luca-Timo/feat/decouple-gallery-header
feat(gallery): decouple header style from layout, add banner option
2026-05-04 15:30:52 +02:00
Paul Nothaft 0adec25fe7 Merge pull request #382 from Luca-Timo/refactor/external-media-types
refactor(events): type external-media list response, drop any casts
2026-05-04 14:46:35 +02:00
Luca d823dcda26 Merge branch 'beta' into refactor/external-media-types 2026-05-04 14:40:01 +02:00
Luca 0bd61be5a6 i18n(gallery): translate banner header strings (nl/pt/ru) 2026-05-04 14:34:55 +02:00
Luca aff29c91bb feat(gallery): decouple header style from layout, add banner option 2026-05-04 14:34:42 +02:00
Paul Nothaft 4d99eb672d Merge pull request #383 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.36.0-beta.0
2026-05-04 13:44:35 +02:00
github-actions[bot] 66281f9790 chore(beta): release 3.36.0-beta.0 2026-05-04 11:33:17 +00:00
Paul Nothaft 3fe8e61bd1 Merge pull request #379 from Luca-Timo/feat/admin-email-picker
feat(events): prefill admin email + admin picker on event creation
2026-05-04 13:32:53 +02:00
Paul Nothaft 06f2b75ec3 Merge pull request #381 from the-luap/chore/external-folder-picker-types
chore(events): type FolderTreeNode entries with ExternalEntry
2026-05-04 13:31:11 +02:00
Luca ffbb0e659f refactor(events): type external-media list response, drop any casts 2026-05-04 12:28:46 +02:00
Paul Nothaft 98c6f6cf06 chore(events): type FolderTreeNode entries with ExternalEntry
Follow-up to PR #378 — drops the (e: any) / (d: any) casts in the
external-folder-tree picker. ExternalEntry is already exported from
externalMedia.service.ts; the call site just wasn't using it.

- Import the type alongside the service.
- Annotate the dirs filter callback so `e.type` is the union 'dir' |
  'file' instead of any.
- Drop the (d: any) annotation from the map — TypeScript infers
  ExternalEntry from the typed `dirs` array.

No behaviour change, no test impact. `npx tsc --noEmit` clean,
`npx eslint` clean.
2026-05-04 12:24:21 +02:00
Paul Nothaft bfd7169fc4 Merge pull request #380 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.35.0-beta.0
2026-05-04 12:21:35 +02:00
github-actions[bot] 7e4f1acb88 chore(beta): release 3.35.0-beta.0 2026-05-04 10:21:13 +00:00
Paul Nothaft cdd40acb45 Merge pull request #378 from Luca-Timo/beta
feat(events): tree view for external media folder picker
2026-05-04 12:20:54 +02:00
Luca 40d23e24cb i18n(events): translate admin email picker strings (nl/pt/ru) 2026-05-04 12:10:23 +02:00
Luca ee56b6762f feat(events): prefill admin email + admin picker on event creation 2026-05-04 12:10:23 +02:00
Luca bd42ee1ce0 fix(events): match scrollbar to theme in external folder tree picker 2026-05-04 11:30:46 +02:00
Luca 954ab00495 i18n(events): translate external folder picker strings (nl/pt/ru) 2026-05-04 11:02:00 +02:00
Luca f927b09c70 feat(events): tree view for external media folder picker 2026-05-04 11:01:49 +02:00
Paul Nothaft 048870804f Merge pull request #377 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.2-beta.0
2026-05-04 09:15:29 +02:00
github-actions[bot] 13e3a05386 chore(beta): release 3.34.2-beta.0 2026-05-04 07:14:40 +00:00
Paul Nothaft 3ab8a64a24 Merge pull request #376 from the-luap/fix/ffmpeg-on-alpine
fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
2026-05-04 09:14:16 +02:00
Paul Nothaft 96818c7ae8 fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
Video uploads on production fail with "missing ffmpeg" because the
backend container ships nothing usable for the video pipeline.

Two compounding causes:

1. **Alpine + glibc mismatch.** The npm `@ffmpeg-installer/ffmpeg`
   dependency added with the video-support PR (commit 68a9dc5)
   ships per-platform binaries via optionalDependencies. The Linux
   binaries are built against glibc, but the backend image runs on
   `node:22-alpine` (musl libc) — known to either fail to execute
   or fail on shared-library lookups on Alpine.

2. **`ffprobe` missing entirely.** `@ffmpeg-installer/ffmpeg`
   bundles only the `ffmpeg` binary. There's a separate
   `@ffprobe-installer/ffprobe` package that the codebase never
   depended on. But `videoProcessor.js:21` calls
   `ffmpeg.ffprobe(videoPath, …)` — the very first step of the
   video pipeline shells out to a `ffprobe` binary that doesn't
   exist in the image. Even if (1) worked, every video upload
   would 500 here.

The fix is to install Alpine's `ffmpeg` package via apk. It ships
both `ffmpeg` and `ffprobe` built natively against musl, ~70MB
extra image size, single line in the Dockerfile, no per-arch
handling needed (apk pulls the right binary for both linux/amd64
and linux/arm64 — works with the multi-arch infra from #349).

- `backend/Dockerfile`: add `ffmpeg` to the apk install line.
- `backend/Dockerfile.dev`: same for dev parity.
- `backend/src/services/videoProcessor.js`: remove the
  `setFfmpegPath(require('@ffmpeg-installer/ffmpeg').path)` line
  — without removing it, fluent-ffmpeg would prefer the broken
  bundled binary over the working apk one. Letting fluent-ffmpeg
  fall back to PATH lookup picks up the apk binary in the
  container and the developer's locally-installed binary on dev
  hosts (Homebrew on macOS, apt on Debian).
- `backend/package.json`: drop the now-unused
  `@ffmpeg-installer/ffmpeg` dependency. `npm install` removes
  2 packages from the lockfile.

Verified: `videoProcessor.js` still loads cleanly (`node -e
"require('./src/services/videoProcessor')"`); lint clean.
2026-05-04 09:04:38 +02:00
Paul Nothaft 8f2639bdca Merge pull request #375 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.1-beta.0
2026-05-04 00:19:37 +02:00
github-actions[bot] eb6791792e chore(beta): release 3.34.1-beta.0 2026-05-03 22:18:02 +00:00
Paul Nothaft 08d046276b Merge pull request #374 from the-luap/fix/cms-external-url-i18n-and-api-shape
fix(cms): nl/pt/ru i18n + gate external_url in public response
2026-05-04 00:17:40 +02:00
Paul Nothaft bce5c1f725 fix(cms): nl/pt/ru i18n + gate external_url in public response
Two follow-ups to PR #372 (external-URL toggle for imprint /
privacy CMS pages):

1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de
   locales but the project ships 5 locales total. Adds the missing
   nl / pt / ru translations so the admin CMS page renders in the
   active language for those users instead of falling back to
   English literals next to the German/Dutch/Portuguese/Russian
   surrounding strings.

2. **API shape.** `publicCMS.js` returned `external_url`
   unconditionally — even when `use_external_url` is false the URL
   value was still emitted in the public response. The frontend
   correctly gated on both flags so it worked, but the API surface
   was leaking a value the admin had explicitly disabled. The
   value still lives in the DB (so the toggle can be flipped back
   on without losing it), but the public endpoint now returns
   `null` whenever the toggle is off.

   Note: kept the existing `logo_url` shape unchanged. Its semantics
   are different — null means "fall back to global branding" and
   consumers rely on always having the field, so emitting it
   unconditionally is intentional there.

No frontend change needed: both `GalleryLayout` and `LegalPage`
already gate on `use_external_url && external_url`, so the
short-circuit handles `external_url: null` correctly.
2026-05-04 00:14:07 +02:00
Paul Nothaft ccd3fd9349 Merge pull request #373 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.0-beta.0
2026-05-04 00:11:41 +02:00
github-actions[bot] cf04c0b2dc chore(beta): release 3.34.0-beta.0 2026-05-03 22:11:01 +00:00
Paul Nothaft b2c8161a43 Merge pull request #372 from Luca-Timo/beta
feat(cms): add external URL toggle for imprint and privacy pages
2026-05-04 00:10:44 +02:00
Paul Nothaft aade003564 Merge pull request #371 from the-luap/i18n/password-reset-modal
i18n(events): translate PasswordResetModal across 5 locales
2026-05-03 23:29:09 +02:00
Paul Nothaft c270bcfc9f i18n(events): translate PasswordResetModal across 5 locales
The rebuilt modal in this PR shipped with hard-coded English strings.
That made the reset flow untranslated for German/Dutch/Portuguese/
Russian customers — toasts, confirm dialog, success screen all
fell back to English regardless of the active locale.

- New `events.passwordReset.*` namespace in en/de/nl/pt/ru with 22
  keys covering both modal screens, the warning banner, validation
  errors, and the toast messages.
- Modal uses `useTranslation()` for every previously hard-coded
  string. Reuses `common.cancel`, `events.copy`, `events.copied`
  where they already exist across all locales.
- The {{eventName}} interpolation uses i18next's standard variable
  syntax so the description line reads naturally in each language.

No behaviour change. TypeScript clean (`npx tsc --noEmit`), ESLint
clean. JSON validity checked for all 5 locale files.
2026-05-03 23:16:21 +02:00
Luca c5bba505ac feat(cms): redirect legal links to external URL when configured 2026-05-03 23:06:16 +02:00
Luca a4e3d10fb0 feat(cms): admin UI for external imprint/privacy URL 2026-05-03 23:05:51 +02:00
Luca 66423bb65e feat(cms): add per-page external URL override — backend 2026-05-03 23:05:18 +02:00
Paul Nothaft c0ca5ed687 Merge pull request #370 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.2-beta.0
2026-05-03 22:49:02 +02:00
github-actions[bot] d552f45b20 chore(beta): release 3.33.2-beta.0 2026-05-03 20:48:36 +00:00
Paul Nothaft 0d1f82d31a Merge pull request #369 from the-luap/fix/admin-set-password-and-full-url-emails
fix(events): admin-set password on reset, full-URL gallery_link in all emails
2026-05-03 22:48:17 +02:00
Paul Nothaft ff50c74e19 fix(events): admin-set password on reset, full-URL gallery_link in all emails
Two related defects on the same gallery-email surface that PR #367
opened, addressed together:

1. Reset-password endpoint was a one-way auto-generate.
   `POST /admin/events/:id/reset-password` always called
   `generateReadablePassword()` and ignored any client-supplied value;
   the modal only offered a confirm + a forced auto-generated result.
   Admins who wanted to set a memorable customer-supplied password
   had no way to do it.

   Backend: route now reads optional `password` from the body. If
   present, validates with `validatePasswordInContext('gallery', …)`
   (same rules as create-event) and uses it; if absent, falls back to
   the existing generator, so old callers / cron stay functional.
   Switched the bcrypt rounds from a hard-coded `10` to
   `getBcryptRounds()` to match the create flow.

   Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with
   show/hide, confirm-password field that appears on type, the same
   `<PasswordGenerator>` used by `CreateEventPage` (event-context-
   aware, fills both fields when used), send-email checkbox,
   client-side validation, server-side validation feedback inline.
   Submit empty → server auto-generates and the success screen shows
   the value with a copy button (legacy one-click flow preserved);
   submit with a typed password → success toast + close (no need to
   re-show what the admin already typed).

   Service layer: `events.service.resetPassword(id, sendEmail,
   password?)` only sends `password` in the body when set.

   Caller: `EventDetailsPage` now passes `eventDate` + `eventType`
   into the modal so the generator has event context.

2. `gallery_link` was the path-only `event.share_link` in three
   email-queue sites, so customer mail showed
   `/gallery/<slug>/<token>` instead of the full
   `https://example.com/gallery/<slug>/<token>` URL.

   - `adminEvents.js` reset-password queue (#1437)
   - `adminEvents.js` resend-creation-email queue (#1502)
   - `expirationChecker.js` expiration_warning queue (#82)

   All three now derive `shareUrl` from `buildShareLinkVariants`
   (the same helper already used by create-event, publish-from-
   draft, and event-rename). The other 4 callers
   (`adminEvents.js:651/913`, `events.js:187`,
   `eventRenameService.js:231`) already used the full URL — this
   closes the gap.

Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on
every touched file (the 4 lint errors that remain in
`adminEvents.js` are pre-existing and predate this branch).
2026-05-03 22:44:22 +02:00
Paul Nothaft d4d8833d82 Merge pull request #368 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.1-beta.0
2026-05-03 22:12:19 +02:00
github-actions[bot] 65d8032960 chore(beta): release 3.33.1-beta.0 2026-05-03 20:10:16 +00:00
Paul Nothaft 07672038d4 Merge pull request #367 from the-luap/fix/email-template-rendering-and-caller-data
fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
2026-05-03 22:09:58 +02:00
Paul Nothaft e8052adf1d fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.

Renderer (`backend/src/services/emailProcessor.js`)

- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
  before flat `{{var}}` substitution. The shipped templates have used
  Handlebars-style conditionals since migration 026; the renderer
  ignored them, so the markers leaked verbatim into every mail with
  an empty welcome_message. Lifted to module scope and exported so
  the conditional contract is unit-testable. Single-pass, non-nested
  (commented).

- Added `passwordSetAtCreationI18n` next to the existing two i18n
  password sentinels so `(set at creation)` (sent by the publish-
  from-draft flow when only the bcrypt hash remains) is localised
  to "Das bei der Erstellung der Galerie gesetzte Passwort" /
  equivalent in EN/DE/NL/PT/RU instead of the raw English string.

- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
  so admin-supplied free text (`event_name`, `host_name`, …) is
  HTML-escaped on substitution into the HTML body. Allowlist of
  passthrough keys (`welcome_message` already-HTML, server-generated
  URLs `gallery_link` / `client_link`). Subject and text body keep
  the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
  before nl2br so the welcome_message allowlist is safe.

- New `htmlToText()` strips `<style>` and `<script>` blocks (and
  their content) before tag-stripping, decodes common entities, and
  collapses whitespace. Used by the textBody fallback in
  `sendTemplateEmail` — without this, every template missing a
  `body_text` produced a "plain-text" mail starting with the 100+
  lines of CSS embedded by `wrapEmailHtml()`.

- The client-access section (#172) now mirrors its HTML block into
  `textBody` using the same per-language strings, so plain-text
  recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
  into `clientAccessI18n` (RU uses ПИН-код).

- Added `getSupportEmail()` exported helper that reads
  `branding_support_email` from `app_settings` (JSON-decoded), with
  the SMTP from-address as fallback. Used by the gallery_expired and
  archive_complete callers below.

- Removed dead `require('handlebars')` (unused since the regex
  renderer landed; pre-existing lint error in this file).

Callers (data the templates already reference)

- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
  (templates use this, the old code sent `expiration_date` —
  typo'd key, never read), drop the hard-coded `.de`/`en` sniff
  (the processor formats with the recipient's resolved language),
  add the `{{password_security_message}}` sentinel for
  `gallery_password` (plaintext is gone by warning time, so
  customers used to see literal `{{gallery_password}}` in the mail).

- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
  now supply `host_name`, `event_date`, `expiry_date`,
  `support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
  doesn't render literal `{{host_name}}, your gallery expired on
  {{expiry_date}}`. Skip the duplicate admin send when
  admin_email == customer_email.

- `archiveService.js`: `archive_complete` queue now supplies
  `host_name`, `photo_count` (from `photoEntries.length`),
  `archive_date`, `support_email` — the previous payload had only
  `event_name` and `archive_size`, so most of the mail was
  unfilled placeholders.

Tests

- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
  16 cases — flat substitution, conditional truthy/falsy/missing/
  multi-line/sibling/numeric-0, plus 5 cases for the new
  `escapeHtml` option (default off, escape on, allowlist
  passthrough for welcome_message and gallery_link).

- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
  the regression scenario (full wrapped body with embedded `<style>`
  block), tag-stripping, entity decoding, paragraph spacing.

- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
  `nl2br`, and the now-escaping `formatWelcomeMessage`.

35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
2026-05-03 22:06:06 +02:00
Paul Nothaft 297960698a Merge pull request #365 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.0-beta.0
2026-05-02 23:04:13 +02:00
github-actions[bot] 4207207c5e chore(beta): release 3.33.0-beta.0 2026-05-02 21:02:15 +00:00
Paul Nothaft df3061893d Merge pull request #349 from Luca-Timo/feat/apple-silicon-support
feat: native multi-arch Docker images (Apple Silicon, ARM64 Linux)
2026-05-02 23:01:54 +02:00
Paul Nothaft 4765804645 Merge pull request #364 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.5-beta.0
2026-05-02 22:59:17 +02:00
Paul Nothaft 907bcf1eb2 Merge pull request #363 from the-luap/feat/upload-redesign-and-auth-loop-fix
feat(upload): async photo processing + fix(auth): /auth/session symmetry (loop fix)
2026-05-02 22:59:03 +02:00
github-actions[bot] 214d8a1899 chore(beta): release 3.32.5-beta.0 2026-05-02 20:59:02 +00:00
Paul Nothaft f529c9e3d7 Merge pull request #362 from the-luap/fix/issue-358-theme-aware-skeleton
fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)
2026-05-02 22:58:43 +02:00
Paul Nothaft b30010eb6f test(upload): unit tests for backgroundProcessor + processPhoto
Cover the two new pieces of the async pipeline:

backgroundProcessor.claimNextPhoto
  - returns null when no pending rows
  - returns the row + flips status under postgres FOR UPDATE SKIP LOCKED
  - returns null when SQLite UPDATE-with-guard loses the race
  - returns the row when the SQLite guard wins

photoProcessor.processPhoto
  - happy path: writes thumbnail / dimensions / EXIF capture date and
    marks 'complete'; fires watermark queue + photo.uploaded webhook
    with the right payload
  - video path: writes ffmpeg duration / codec / dimensions; does NOT
    queue watermark (image-only)
  - throws cleanly when the photo row no longer exists

Mocks db / imageProcessor / videoProcessor / storage / sharp /
watermarkGeneratorService / webhookService / logger so the tests run
without a real DB or any image library calls — fast and deterministic.
2026-05-02 22:56:04 +02:00
Paul Nothaft 3b827b80d5 feat(upload): async photo processing — frontend (PR-B part 2)
Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.

services/uploads.service.ts (new)
  - getStatus(uploadId)        — JSON snapshot from /admin/uploads/:id/status
  - retryPhoto(photoId)        — POST /admin/photos/:id/retry
  - streamUrl(uploadId)        — SSE upgrade URL

hooks/useUploadProgress.ts (new)
  - Tracks N concurrent upload IDs (one per chunk POST) and merges
    counters into a single aggregate.
  - Always polls every 1.5s; opportunistic SSE upgrade on top of that.
    SSE failure (proxy buffering, etc.) silently downgrades to polling
    only — no reconnect storms.
  - Auto-stops both channels when every tracked group is in a terminal
    (complete/failed) state.

components/admin/PhotoUpload.tsx
  - Captures upload_id from each chunk's 202 response, feeds them into
    useUploadProgress.
  - Phase machine extended: stays in 'processing' until the worker
    drains the queue (not just until bytes-on-wire). Progress UI shows
    real "X of N done" with a determinate bar fed by the aggregate.
  - "You can leave this page" hint kept — closing the modal is now
    actually safe, work continues server-side.
  - Side-effect refactor: invokes onUploadComplete twice — once early
    so the user sees photos appearing immediately, once on terminal
    so the parent grid sees final state.

components/admin/AdminPhotoGrid.tsx
  - Photos with processing_status pending/processing render an amber
    placeholder card with a spinning Cog instead of the missing
    thumbnail.
  - Photos with status='failed' render a red card with the error message
    and a "Retry" button that POSTs /admin/photos/:id/retry.

pages/admin/EventDetailsPage.tsx
  - Photo list query gains refetchInterval that polls every 2s while
    any photo is non-terminal, then stops. Keeps the grid auto-fresh
    during ongoing processing.
2026-05-02 22:56:04 +02:00
Paul Nothaft 851744c3c4 feat(upload): async photo processing — backend (PR-B part 1)
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.
2026-05-02 22:56:04 +02:00
Paul Nothaft 86dfcc4f11 feat(upload): two-state UI + temp dir cleanup (PR-A of async processing)
Phase 1 of the upload-progress redesign. Two changes that ship UX wins
without any architectural surgery — they're a stepping stone for the
full async-processing rework that follows in subsequent commits.

1. Two-state progress bar (PhotoUpload.tsx, UserPhotoUpload.tsx)

   When axios.onUploadProgress reports loaded === total, the request is
   on the server and the bytes have left the browser. Today the bar sits
   at 100% for the chunk while the backend runs sharp/ffmpeg/EXIF (often
   minutes on NFS-backed storage) and users assume the upload froze.

   The component now distinguishes two phases:
   - 'transferring' — bytes-on-wire, determinate progress bar.
   - 'processing'   — bytes done, waiting for response. Indeterminate
                      spinner + an explanatory hint that the backend is
                      generating thumbnails / reading metadata and the
                      user can leave the page.

   Same pattern in UserPhotoUpload (gallery): the per-file checkmark
   icon is replaced by a Loader2 spinner while the request is in flight
   after bytes-on-wire finished.

2. Temp directory cleanup (adminPhotos.js)

   Multer creates temp/upload_<ts>_<rand>/ per request. Files inside it
   are individually unlinked after they're moved to storage on the
   success path, but the empty directory was never removed. On error
   paths three different inline blocks each tried to clean up; the
   success path was missed entirely. Result: the orphan-empty-dirs
   accumulation reported in the issue (70+ on the affected instance).

   Replace the inline cleanup blocks with a single idempotent
   cleanupTempDir() registered on res.finish + res.close, so it fires
   exactly once on every exit path (validation 4xx, server 5xx, multer
   error, success).

New translation keys (en/de): upload.transferring, upload.processing,
upload.processingHint, upload.processingProgress, upload.processingFailed,
upload.retryFailed.
2026-05-02 22:56:04 +02:00
Paul Nothaft f905f7e733 fix(auth): /auth/session must reject tokens that adminAuth/galleryAuth would reject
Second loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355. The frontend trusts /auth/session as the source of
truth for "is the user authenticated?". When that endpoint is more
lenient than the protected middleware, every admin endpoint 401s
right after /auth/session said valid:true, the response interceptor
hard-redirects to /admin/login, /auth/session says valid again, and
the cycle closes — exactly the loop reported on v3.32.4-beta.0.

#355 fixed the issuer-claim asymmetry. This commit fixes the
remaining asymmetries: /auth/session was missing the admin-existence,
admin-active, password-change-after-iat, and gallery-existence /
gallery-archived / gallery-expired checks that adminAuth and
galleryAuth perform on every protected request.

The fix is to mirror those checks in /auth/session, scoped by token
type, and degrade gracefully when the underlying tables aren't
present (test fixtures, early bootstrap) so the endpoint never
fails-closed because of a missing table.

Reproducer that the new test covers:
  1. Admin logs in (token issued at T).
  2. Admin (or another admin) changes their own password at T+1.
  3. Browser still has the cookie from T.
  4. /auth/session says valid:true (no password-change check).
  5. /admin/dashboard fires queries; adminAuth rejects with
     PASSWORD_CHANGED 401.
  6. Frontend redirects to /admin/login.
  7. /auth/session says valid:true again. → loop.

Other surfaces this also covers:
  - admin user deactivated (admin_users.is_active = false)
  - admin user deleted
  - gallery token whose event is archived
  - gallery token whose event has expired

Tests live in __tests__/routes/authSession.symmetry.test.js — 9 cases,
mocking db / tokenRevocation / tokenUtils / recaptcha / sessionTimeout
so the suite runs without a real database.
2026-05-02 22:56:04 +02:00
Paul Nothaft 7b2f75e6ae chore: bump @playwright/test to ^1.57.0; drop unused root dotenv
The root devDependencies still pinned an older Playwright (1.48.2)
plus a stray `dotenv` that nothing in the e2e suite or root scripts
actually requires (verified via grep across tests/). Updates the
Playwright version to match the current upstream stable and removes
the unused dotenv to keep the root install lean.

Originated from a local stash that picked up these changes; landing
them as a small dedicated commit so they don't blend into the auth
fix that follows.
2026-05-02 22:56:04 +02:00
Paul Nothaft 1a530aeaa2 fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)
Two further fixes for the gallery loading sequence shown in
@Rekoo-PS's frame breakdown on issue #358 — both about colours that
didn't track the active theme.

1. Initial white frame (frame f1)

   The pre-React bootstrap script in #359 sets the cached background
   on documentElement, but the browser may paint the very first frame
   *before* that <script> tag runs (synchronous parse-time JS in the
   <head> is still slightly later than CSS apply-time). On first-visit
   dark-OS devices that meant a single white frame before the script
   resolved.

   Fix: move the OS-preference default into a <style> block that
   precedes the script. CSS @media (prefers-color-scheme) is applied
   before paint, so dark-OS devices land on dark from frame zero.
   The script keeps the per-gallery cache hit on top, and now also
   stamps the colour onto document.body in case the body element has
   already mounted by the time the script runs.

2. "Most annoying" skeleton tile frame (frame f4)

   Skeleton placeholders rendered as bright `bg-neutral-200` light grey
   regardless of theme. On a dark gallery that's the highest-contrast
   thing on screen during loading — the exact frame Rekoo-PS labelled
   "the most annoying" in the issue.

   Fix: the Skeleton component's background now reads
   `var(--color-surface-border)`, which ThemeContext already wires up
   per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per-
   event themes can override). The bare `<div>` no longer carries any
   colour utility class — the inline style supplies the active value.
   Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable
   in favour of `var(--color-surface)` for the same reason.

Tests:
   New src/components/common/__tests__/Skeleton.test.tsx covers
   - Skeleton uses var(--color-surface-border, ...)
   - bg-neutral-200 is no longer present
   - SkeletonGalleryGrid tiles all inherit the theme colour
   - SkeletonCard surface uses var(--color-surface)
2026-05-02 22:52:09 +02:00
Luca c7ac9ddfe5 refactor: rename mac override to amd64 override for arch accuracy 2026-05-02 01:34:46 +02:00
Luca 3440ecc999 ci: lowercase image names for GHCR compatibility on forks 2026-05-02 01:26:47 +02:00
Luca ede5193e58 Update docker-build.yml 2026-05-02 01:26:47 +02:00
Luca ec2eaf76ea ci: build multi-arch images on every channel via native arm64 runners 2026-05-02 01:26:47 +02:00
Luca c282a72bd3 feat: support Apple Silicon natively via multi-arch images 2026-05-02 01:26:47 +02:00
Paul Nothaft ac040fbef8 Merge pull request #361 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.4-beta.0
2026-05-02 00:11:05 +02:00
github-actions[bot] 1fae9b6099 chore(beta): release 3.32.4-beta.0 2026-05-01 22:07:34 +00:00
Paul Nothaft af2b0628cb Merge pull request #360 from the-luap/fix/issue-hero-logo-position
fix(events): stop mapping branding_logo_position onto hero_logo_position
2026-05-02 00:07:24 +02:00
Paul Nothaft 07b41e691d Merge pull request #359 from the-luap/fix/issue-358-theme-flash
fix(theme): pre-React bootstrap to kill white-flash on dark galleries (#358)
2026-05-02 00:07:04 +02:00
Paul Nothaft ef1c875f6e fix(events): stop mapping branding_logo_position onto hero_logo_position
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.
2026-05-02 00:04:06 +02:00
Paul Nothaft f81a8728e6 fix(theme): pre-React bootstrap to kill white-flash on dark galleries (#358)
Opening a gallery with a dark theme briefly painted a white background
between the initial HTML render and React applying the per-event theme.
The HTML shipped with no theme info, so the first paint used the
default (#fafafa) before /gallery/:slug/info resolved.

Two-part fix.

1. Inline bootstrap script in index.html runs synchronously before React
   mounts. Reads the URL, looks up a per-slug background colour from
   localStorage (gallery-theme-bg-<slug>), and applies it to
   documentElement immediately. Falls back to #171717 when no cache
   exists and the OS prefers dark, so first visits with dark OS still
   land on a dark background.

2. ThemeContext.applyTheme writes the resolved background to
   localStorage keyed by slug whenever a gallery theme loads. Revisits
   then hit the bootstrap cache and never see a flash.

Added a 200ms transition on html.background-color so the rare
cache→API drift (e.g. theme palette changed admin-side since last
visit) is a smooth fade instead of a snap.

Limitation: first visit on a light-OS device to a dark gallery still
flashes once. Killing that case requires a server-rendered theme hint,
out of scope for an SPA bootstrap fix.

The empty-skeleton-grid part of the same report is already addressed
by the 300ms lazy render in #352 — Rekoo-PS just needs to update from
v3.32.1-beta.0 to v3.32.2-beta.0+.
2026-05-01 23:53:55 +02:00
Paul Nothaft 9597333ddc Merge pull request #357 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.3-beta.0
2026-05-01 23:44:57 +02:00
github-actions[bot] 03dd99a8b1 chore(beta): release 3.32.3-beta.0 2026-05-01 21:29:08 +00:00
Paul Nothaft e5712d8ffe Merge pull request #356 from the-luap/fix/issue-create-event-expiry-date-coercion
fix(events): coerce expires_in_days to Number before addDays
2026-05-01 23:28:40 +02:00
Paul Nothaft 83dedbcd45 Merge pull request #355 from the-luap/fix/issue-350-jwt-verify-symmetry
fix(auth): /auth/session must verify issuer claim like adminAuth (#350)
2026-05-01 23:28:26 +02:00
Paul Nothaft db29d0e278 fix(events): coerce expires_in_days to Number before addDays
The "Expires on" preview under the days-after-event input rendered
nonsense dates (e.g. 25.04.2026 + 120 days → 08.01.2095, ~68 years
out). Cause: handleInputChange stores e.target.value verbatim, which is
a string for <input type="number">, so formData.expires_in_days is "120"
not 120. date-fns addDays does:

  _date.setDate(_date.getDate() + amount)

When amount is a string, the + is string concatenation:
25 + "120" = "25120". setDate("25120") then sets day-of-month to 25120,
which carries over by ~68 years.

Fix: cast to Number at the call site. The validation/API-payload
codepaths already work because the comparisons at line 330 and the
JSON payload coerce numerically through different paths — only addDays
was actually broken.

The TypeScript type FormData.expires_in_days: number is a lie because
handleInputChange's [field]: e.target.value sets a string regardless.
Tightening that handler is a separate cleanup; this commit only fixes
the visible date bug.
2026-05-01 23:22:56 +02:00
Paul Nothaft 88a6c6a7fb fix(auth): make /auth/session verify the issuer claim like adminAuth (#350)
Asymmetric JWT verification was causing a /admin/login → /admin/dashboard
→ /admin/login redirect loop for users carrying admin cookies issued
before the iss: 'picpeak-auth' claim was added (commit 23cd9cb,
"address Shannon security assessment findings (37 vulnerabilities)").

The frontend uses GET /auth/session as the source of truth for "is the
user authenticated?". That endpoint called jwt.verify(token, JWT_SECRET)
with no issuer option, so it accepted pre-issuer tokens and reported
valid: true. AdminLoginPage then redirected to /admin/dashboard, every
protected endpoint went through adminAuth which DOES verify the issuer,
each one rejected the token with 401, the response interceptor
window.location.href'd back to /admin/login, and the loop closed.

Fix: pass { issuer: 'picpeak-auth' } to /auth/session's jwt.verify so it
matches adminAuth and galleryAuth. Tokens without the claim now correctly
return valid: false from the session check, AdminLoginPage shows the
login form, and a fresh login mints a properly-issued cookie.

The other intentionally-lax verify call sites (logout-flow logging,
photoAuth, sessionTimeout, rateLimit) are unrelated to the loop and stay
lax — their callers don't gate "authenticated?" decisions on the result.

Reproducer: open a removed/archived gallery URL with a stale admin
cookie from before the issuer claim was added, click "Back to home" on
the gallery-not-found page → loop.
2026-05-01 23:12:20 +02:00
Paul Nothaft 8f7258bfc8 Merge pull request #353 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.2-beta.0
2026-05-01 22:48:31 +02:00
github-actions[bot] 34fdddef51 chore(beta): release 3.32.2-beta.0 2026-05-01 20:37:32 +00:00
Paul Nothaft 6229b38bac Merge pull request #352 from the-luap/fix/issue-321-346-348-and-discussions
fix: events search/counters (#346), lazy gallery skeleton (#321), smooth lightbox swipe (#348)
2026-05-01 22:37:16 +02:00
Paul Nothaft 743086d3cb fix(lightbox): smooth carousel swipe + drop instructional hint (#348)
Two fixes for discussion #348.

Carousel-style swipe
The lightbox previously snapped to the next photo on swipe, then showed
a loading spinner while the new image fetched — choppy compared with
the reference video the reporter shared. The current photo is now
rendered inside a 3-slide track (prev/current/next). As the finger
drags, the track follows; on release the track animates to the
neighbouring slot or springs back if the gesture didn't pass the
threshold. Because the prev/next AuthenticatedImages render up front,
the browser starts fetching them while the user is still on the
current photo, so there's no loader flash on commit.

- Phase machine ('idle' | 'dragging' | 'committing' | 'springing')
  drives the track's transform/transition. Commit + spring use a 280ms
  cubic-bezier ease.
- Percentage-based transforms avoid measuring container width before
  the first paint. Commit threshold (read from the ref on demand) is
  max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least
  40px of movement).
- transitionend advances currentIndex with wrap-around and resets the
  track in one batch — slot contents rotate and the track snaps from
  the commit position back to centered with transition: none, so the
  visible image stays put. No flicker.
- Vertical-cancel (>24px dy) abandons the drag and springs back so the
  user keeps the gesture they intended.
- touch-action: none on the carousel container stops the browser
  fighting us with edge-swipe back navigation and native pinch-zoom.
- Pinch starting mid-drag springs the track back smoothly so the image
  doesn't jerk under the second finger.
- onTouchCancel covers system-interrupted gestures (incoming call etc).
- dragX === 0 short-circuits to 'idle' instead of 'springing' so taps
  don't get stuck waiting for a transitionend that never fires.
- Neighbour slides use a simplified AuthenticatedImage render (no
  canvas/fragment-grid pipeline) since they're only on screen during
  the swipe; the current slide keeps the full protection chain.
- Neighbour videos render their thumbnail rather than spinning up a
  VideoPlayer. When the *current* photo is a video, the carousel is
  bypassed entirely — single VideoPlayer + no swipe handlers — because
  sliding a video element during a drag is awkward and adds nothing.
- Removed the now-redundant imageLoaded state + spinner;
  AuthenticatedImage already shows a placeholder while loading.

Keyboard arrows and the on-screen Prev/Next buttons still snap (no
animation) — animating them would have required input queuing for
fast double-presses, and the request was specifically about swipe.

"Swipe to navigate" hint
Removed the mobile-only overlay text. Swipe is universal in image
viewers; the instruction read like training wheels and competed with
the photo for attention.
2026-05-01 20:55:42 +02:00
Paul Nothaft d9d81372b8 fix(gallery): lazy-render skeleton grid for fast loads (#321 follow-up)
The gallery loading skeleton now renders the header bars immediately but
delays the 12-tile placeholder grid by 300ms. Galleries that load
quickly (the common case) never flash the empty grid before the real
photos render — addressing the follow-up reported on #321 — while
slower loads still get a placeholder so the page doesn't sit blank.
2026-05-01 20:55:19 +02:00
Paul Nothaft a5b20ca3fe fix(events): server-side search/pagination to remove first-100 cap (#346)
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.

Backend
- adminEvents.js: extend search to include customer_email so the column
  shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
  page can render an accurate "All (N)" / Total Events counter without
  walking the full table on the client.

Frontend
- events.service.ts: getEvents() now accepts search + the full status
  enum (active|inactive|archived|draft|expiring); response type matches
  the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
  filter, and 300ms-debounced search; Prev/Next + range/page indicator
  below the table; placeholderData keeps the previous page visible
  during fetches; stat cards and "All (N)" pull from /dashboard/stats so
  totals stay accurate regardless of the visible page; archive/delete
  invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
  'expiring') directly instead of slicing the first 100 client-side. As
  a side effect the dashboard's "expiring" definition now matches the
  backend (was excluding events expiring within the next 24h).
2026-05-01 20:55:13 +02:00
Paul Nothaft 92847bc06b Merge pull request #345 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.1-beta.0
2026-04-30 09:09:37 +02:00
github-actions[bot] 7e65921ba6 chore(beta): release 3.32.1-beta.0 2026-04-30 07:08:45 +00:00
Paul Nothaft 02ed5d4007 Merge pull request #344 from the-luap/chore/move-docs-to-picpeak-app
docs: move documentation to docs.picpeak.app, drop in-repo copies
2026-04-30 09:08:16 +02:00
Paul Nothaft 0faf9b3281 docs: move documentation to docs.picpeak.app, drop in-repo copies
The full documentation now lives at https://docs.picpeak.app — built
from the picpeak-docs Nextra repo. The README, SIMPLE_SETUP, and the
v1 OpenAPI generation flow all point there now.

Removed (now living at docs.picpeak.app):
- DEPLOYMENT_GUIDE.md (root-level — content covered by docs.picpeak.app/deployment)
- docs/ADMIN_SETUP_GUIDE.md
- docs/JWT_SECRET_MIGRATION.md
- docs/SECURITY_BEST_PRACTICES.md
- docs/admin-api-quickstart.md → docs.picpeak.app/api
- docs/nginx-fix.md → docs.picpeak.app/deployment/reverse-proxy
- docs/openapi.json, docs/openapi.yaml → still generated locally as a
  build artifact (now gitignored), synced into picpeak-docs by
  scripts/sync-api-docs.sh
- docs/picpeak-admin-api.openapi.yaml → ditto

Kept:
- docs/*.png (logo + screenshots — README still img-tags these)

Updated:
- README.md — replaced six in-repo doc links with docs.picpeak.app
  pointers, restructured the Documentation section as a curated link
  list to the new site
- SIMPLE_SETUP.md — single deployment-guide link redirected
- .gitignore — docs/openapi.{json,yaml} are now build artifacts, not
  tracked
- backend/src/routes/v1/events.js — comment clarifies the OpenAPI flow
2026-04-29 22:24:15 +02:00
Paul Nothaft 39af382eb2 Merge pull request #343 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.0-beta.0
2026-04-29 20:43:46 +02:00
github-actions[bot] 784c92fc4d chore(beta): release 3.32.0-beta.0 2026-04-29 18:35:09 +00:00
Paul Nothaft 7ea4801544 Merge pull request #342 from the-luap/feat/webhook-payload-enrichment
feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
2026-04-29 20:34:46 +02:00
Paul Nothaft 1e69d5ff71 feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.

Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:

  { id, slug, event_name, event_type, event_date,
    share_url, share_token,
    customer_name, customer_email, customer_phone }

Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.

Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)

PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
  warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
  null contract, and a Callout warning.

Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
2026-04-29 20:32:20 +02:00
Paul Nothaft 1b1d816009 Merge pull request #340 from the-luap/refactor/settings-nav-grouped
refactor(settings): grouped left-rail nav replaces overflowing tab bar
2026-04-29 00:03:04 +02:00
Paul Nothaft f171f6b974 refactor(settings): grouped left-rail nav replaces overflowing tab bar
The Settings page packed 13 tab buttons into a single horizontal nav
that overflowed even at 1440px — items wrapped or got clipped, and
"Webhooks" disappeared off the right edge entirely. Pattern was the
right call at 5 tabs and broken at 13.

Replaces the flat row with the macOS Settings / Stripe / GitHub pattern:

- **Desktop (lg+)**: 220px sticky left rail with five labelled groups —
  General, Display, Privacy & Security, Integrations, System — and a
  lucide icon next to every item. Active state uses the existing primary
  token. Adds a section header on the right pane that echoes the active
  item so the context is obvious after a switch.
- **Mobile (< lg)**: native <select> with <optgroup> per category. One
  tap to switch, no horizontal scroll, screen-reader friendly.

Categories chosen to be balanced (avg 2.6 items/group) and to map to
how admins actually think about these settings rather than alphabetical
or insertion order. Ports the existing inline-fallback i18n pattern for
the new group labels.
2026-04-28 23:59:54 +02:00
Paul Nothaft 625711af96 Merge pull request #339 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.31.1-beta.0
2026-04-28 22:15:23 +02:00
github-actions[bot] c5a2ec3842 chore(beta): release 3.31.1-beta.0 2026-04-28 16:06:38 +00:00
Paul Nothaft 1e4067713c Merge pull request #338 from the-luap/fix/post-329-bug-triage
fix: mobile lightbox + share previews + customer phone bug triage
2026-04-28 18:06:15 +02:00
Paul Nothaft 42a7ae4be8 fix(lightbox): mobile toolbar clipping + iOS safe-area + viewport-fit (#336)
When feedback was enabled the lightbox bottom toolbar packed counter +
zoom + download + like + 5-star + comments into a single row that
overflowed the viewport on iPhone-class widths, putting the rating
stars under the screen edge and below the iOS home indicator.

Changes:
- Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile,
  so all controls fit (375px viewport: max-right 363 < 375; 390px:
  max-right 378 < 390; 393px: max-right 393 < 393).
- pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row
  sits above the iOS home indicator on devices with a gesture bar.
- Close button top/right now use max(1rem, env(safe-area-inset-*)) so
  it doesn't disappear under the notch / dynamic island.
- "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it
  clears the now-taller wrapped toolbar.
- index.html viewport meta gains viewport-fit=cover to enable
  env(safe-area-inset-*) on iOS Safari.

Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14
(390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape
(852x393) — toolbar fits, photo centered, no clipping.
2026-04-28 16:50:44 +02:00
Paul Nothaft fcddfe094b fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332)
Found via real-browser verification: with useState the prior commit's
handleTouchEnd captures swipeStart from its render closure, so when
touchstart and touchend fire inside the same React batch (fast swipe,
synthetic events, or a tight render cycle) the end handler reads the
stale null and skips navigation. useRef sidesteps the closure entirely
and is the right primitive for cross-event scratchpad state anyway.

Verified in a 4-photo gallery on mobile-emulation (390x844 touch):
- left swipe (-200px) advances 1/4 → 2/4
- right swipe (+200px) returns 2/4 → 1/4
- 20px swipe (under threshold) does not navigate
- vertical swipe (dy 300, dx 20) does not navigate
2026-04-28 15:55:09 +02:00
Paul Nothaft 5275621fcd fix(share): OG/Twitter-card metadata for gallery share URLs (#333)
WhatsApp / Slack / Facebook / Twitter previews showed nothing useful for
shared gallery links — the SPA's stub index.html has no OG tags and the
meta-injection in DynamicFavicon happens at runtime, which crawlers
never see (they don't execute JS).

Add a backend OG handler at /og/gallery/:slug that returns minimal HTML
with proper og:* and twitter:* meta sourced from the event row + branding
settings (event name, formatted date, welcome_message excerpt as
description, configured logo as the preview image, FRONTEND_URL-based
canonical). Honours slug redirects so renamed galleries still get rich
previews.

Wire crawler detection in both nginx configs (production and dev) — UA
match against the standard list (facebookexternalhit, WhatsApp, Slackbot,
Twitterbot, Discordbot, LinkedInBot, etc.) triggers an internal
rewrite to /og/gallery/:slug, while humans fall through to the SPA via
try_files. The OG endpoint is also wired into the native-install SPA
fallback in server.js for setups that bypass nginx.

The OG image is intentionally the brand logo, not a gallery photo —
crawlers fetch it without auth, and password-protected gallery photos
must not leak via share previews.
2026-04-28 15:05:44 +02:00
Paul Nothaft 4c8eba0cb4 fix(gallery): single-finger swipe nav in mobile lightbox (#332)
The lightbox showed a "Swipe to navigate" hint on mobile, but the touch
handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe
fell through and the user could only navigate with the on-screen arrows.

Add a 1-finger swipe detector: track the initial touch position, and on
touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious /
goToNext when the horizontal swipe exceeds 50px, dominates over
vertical motion (1.2x), and completes within 600ms. Suppressed while
zoomed in so the user can pan the image instead.
2026-04-28 15:05:30 +02:00
Paul Nothaft 4c73d228ed fix(events): show customer phone in event details view (#331)
The phone field added in #322 was wired into the edit form but never
rendered in the read-only event-info panel, so admins could only see the
number while editing. Add a phone row gated on event_phone_field_enabled
(same toggle the form uses), and tighten the Event type so customer_phone
is no longer accessed via `(event as any)`.
2026-04-28 15:05:22 +02:00
Paul Nothaft ca8acacd43 Merge pull request #335 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.31.0-beta.0
2026-04-28 14:53:06 +02:00
github-actions[bot] f58b52a9d1 chore(beta): release 3.31.0-beta.0 2026-04-28 12:51:00 +00:00
Paul Nothaft 06d54bec4d Merge pull request #334 from the-luap/feat/post-319-fixes-and-features
feat: S3 storage + webhooks + settings dedupe + backup fixes
2026-04-28 14:50:35 +02:00
Paul Nothaft e232f9f2cf fix(backup): incremental backups against S3 + jsonb stats parsing
Three fixes uncovered while bringing the backup-s3 integration suite to
12/12 against MinIO + Postgres:

- backupService.getDatabaseBackupInfo: pg's jsonb driver auto-parses
  `statistics` / `table_checksums` to objects; the old JSON.parse() then
  threw "[object Object]" is not valid JSON and the manifest dropped
  database info silently. Accept both string and object inputs.

- backupService.runBackup: incremental path called
  backupManifest.loadManifest() with an s3:// URI directly, which falls
  through to fs.readFile() and ENOENTs — every "incremental" backup
  silently downgraded to a full one. Added loadManifestFromAnywhere()
  helper that downloads s3:// to a tmp file before delegating.

- backupManifest.generateIncrementalManifest: attached the `incremental`
  section AFTER generateManifest() had already stamped
  verification.total_checksum, so every incremental manifest failed
  validateManifest() on read-back. Recompute the checksum after.

Test side: updated assertions to the current manifest shape
(`incremental.changes.modified_files_count`), Number()-coerce bigint
columns from pg, and gate the logger mock on UNMOCK_LOGGER for
diagnosing similar silent-failure modes in the future.
2026-04-28 10:52:49 +02:00
Paul Nothaft ab4095f592 fix(backup): cron schedule mapping + manifest format detection + bigint coerce
Three pre-existing bugs surfaced by re-running the backup-s3 integration
suite. backup-s3 went 0/12 → 7/12 (storage-refactor session bootstrap
fixes) → 10/12 with this commit.

1. Backup service crashes on backend startup with
   `TypeError: Cannot read properties of undefined (reading 'replace')`
   from node-cron's expression parser.

   Root cause: `backup_schedule` stores a UI label like "weekly", while
   `backup_schedule_cron` stores the actual cron expression. Startup
   code read the label and passed it straight to cron.schedule() —
   "weekly" is not a cron expression.

   Fix in startBackupService(): read backup_schedule_cron first; fall
   back to mapping known labels (hourly/daily/weekly/monthly) to cron
   expressions; back-compat for deployments that wrote a cron expression
   into the legacy backup_schedule field.

2. Backup manifest retrieval fails with
   `SyntaxError: Unexpected token 'a', "applicatio"...` when the
   manifest format is YAML.

   Root cause: getBackupManifest() downloads the s3:// manifest to a
   tmp file hardcoded as `manifest-N.json`. loadManifest() then
   detects format from extension only — sees .json, runs JSON.parse on
   YAML content (which starts with "application: …"), fails.

   Fix in backupManifest.loadManifest(): detect format from BOTH the
   extension AND the content's first non-whitespace character. JSON
   starts with { or [; anything else falls through to yaml.load.
   Backwards compatible — extension is still authoritative when present
   AND content matches.

3. Test assertion `expect(backupRun.total_size_bytes).toBeGreaterThan(0)`
   fails with "received value must be a number or bigint" because pg
   driver returns bigint columns as strings. Coerce via Number() in
   the test.

Remaining 2 failures (out of scope here, both are spec-level drift):
- "should include database backup" expects the runBackup() flow to
  upload the database backup file at S3 key `database/db-backup.sql`.
  Current implementation reads db backup metadata for the manifest but
  does not upload the file itself. Missing feature, not a test bug.
- "should only upload changed files" expects manifest.incremental.
  modified_files_count. Implementation writes backupType: 'incremental'
  on the run row but no per-run incremental subobject in the manifest.
  Field shape mismatch.
2026-04-28 10:15:46 +02:00
Paul Nothaft 446d80a4cc feat: presigned download UI + S3 prefix walker auto-importer (follow-ups)
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.
2026-04-28 10:08:21 +02:00
Paul Nothaft c488f481ca feat: outbound webhooks for event/photo lifecycle (#327)
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.
2026-04-28 10:07:39 +02:00
Paul Nothaft 1b717ce5ed feat: native S3 storage backend (#328) + presigned download follow-up
Lets PicPeak write photos, thumbnails, hero images, watermarks, and
archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2,
Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local
filesystem. Selected via STORAGE_BACKEND=local|s3.

Architecture
- backend/src/services/storage/StorageBackend.js — abstract interface
  (put/get/exists/stat/delete/list/copy/rename/signedUrl/putFromFile/
  getToFile) — typedef-only, documents the contract.
- LocalFsStorage.js — wraps fs with atomic-write-via-tmp-rename, path
  traversal protection, list-as-walker.
- S3StorageBackend.js — thin wrapper around the existing
  S3StorageAdapter (used by backupService) mapping it onto the canonical
  interface; supports optional STORAGE_S3_PREFIX namespace.
- index.js — factory selected by STORAGE_BACKEND with startup ping
  (HEADs sentinel key on S3, fs.stat on local) so misconfig fails fast
  before the first request.

Consumer refactors (~12 services + routes), each parametrized over the
abstraction:
- imageProcessor / videoProcessor — pipe Sharp/ffmpeg output through
  storage.put; expose withLocalCopy() helper for S3-mode regeneration
  paths that need a local file for sharp/ffmpeg.
- archiveService / downloadZipService — finalize zip in tmp dir, then
  storage.putFromFile. Atomic-rename pattern preserved on local; S3
  emulates via copy + delete (worker prunes orphaned .tmp.* on startup).
- photoProcessor / photoReplacementService / adminPhotos upload+delete /
  routes/v1/events.js POST /events/:id/photos / routes/events.js — every
  upload path now goes storage.putFromFile(temp) → unlink temp.
- gallery.js bulk-download (cached + on-the-fly + selected) — managed
  photos via storage.get, external-mode unchanged.
- protectedImages / secureImages / photoResolver — read via
  storage.get; resolvePhotoStorageKey returns the canonical key.
- watermarkService / watermarkGeneratorService — persistent watermarks
  via storage.put.
- fileWatcher — bails out with a clear log warning when STORAGE_BACKEND=s3
  (chokidar can't watch S3); auto-import lands via the S3 prefix walker
  introduced in the follow-up commit.
- expirationChecker — small touch (event.expired webhook fire from #327
  shipping in the next commit).

Migration tooling
- backend/scripts/migrate-storage.js — one-shot --dry-run capable script
  that walks photos.path, thumbnail_path, hero_path, watermark_path and
  events.archive_path/download_zip_path; streams local → S3; sha256
  size-match skip for idempotent re-run; failures CSV.

Presigned-URL "Download All" (#328 follow-up shipped in this commit)
- routes/gallery.js — when STORAGE_BACKEND=s3 + event.allow_presigned_download
  + downloads enabled + watermark NOT enabled, /download-all returns a
  302 redirect to a 5-minute presigned S3 URL. Per-event opt-in surface
  ships in the next commit's UI.

Tests
- backend/__tests__/integration/storageBackend.test.js — parametrized
  contract suite running against BOTH LocalFs AND MinIO (18 tests, both
  backends — 36 cases total).
- backend/__tests__/integration/imageProcessor.storage.test.js — same
  parametrized pattern for the image processor (10 tests × 2 backends).
- backend/__tests__/integration/backup-s3.test.js — bootstrap fix:
  drop the redundant initDb() (001_init handles it) and remove
  schema-drift in configureS3Backup (app_settings has no created_at
  anymore and the unique constraint is on setting_key alone, not
  composite). 0/12 → 7/12 (5 remaining are unrelated assertion drift).
- backend/src/services/photoResolver.js — mixed-source events (reference
  mode with managed-uploaded photos) now fall back to managed when
  external_relpath is missing instead of throwing.
- tests/e2e/s3-storage-roundtrip.spec.ts — Playwright spec that
  auto-skips against local backend; full upload → serve → delete
  round-trip when run against an S3-mode backend.

Server wiring (server.js)
- initStorage() called after database init, before rate limiters.
- This commit's diff also includes the webhook delivery worker startup
  and the S3 auto-importer startup. Those features ship in the next two
  commits — co-located here for one bisectable diff per file.

Docs + ops
- README §"Storage Backends" — capability matrix, switching playbook,
  IAM policy snippet, MinIO/R2/B2 examples.
- README §"Webhooks" — also added here (full diff bundled).
- .env.example — STORAGE_BACKEND + STORAGE_S3_* + STORAGE_AUTO_IMPORT
  documented; WEBHOOK_* added in the same diff.
- .gitignore — re-anchor the existing `storage/` rule to `/storage/`
  so backend/src/services/storage/ (the new abstraction code) is
  trackable. The runtime ./storage/ data dir stays ignored.

Out of scope for v1 (per the issue): presigned URLs for individual
photo display (always streamed for protection middleware), CDN
integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket
per-event.
2026-04-28 10:06:36 +02:00
Paul Nothaft 3d4ae4d7e9 feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.

Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.

Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
  60s staleTime, queryKey ['public-settings']. Vitest with mocked api
  proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
  RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.

Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
  refetchInterval to preserve maintenance polling), MaintenanceWrapper
  (drops the now-redundant per-route ping; axios interceptor already
  handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
  AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
  ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
  LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
  CreateEventPage. EventDetailsPage Round D ships in the follow-up
  commit that adds presigned-download UI on the same page.

App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
2026-04-28 10:01:53 +02:00
Paul Nothaft 2794ed6722 Merge pull request #330 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.30.0-beta.0
2026-04-27 23:53:57 +02:00
github-actions[bot] ca0e48eb68 chore(beta): release 3.30.0-beta.0 2026-04-27 20:41:31 +00:00
Paul Nothaft 11de7b65c9 Merge pull request #329 from the-luap/feat/post-319-fixes-and-features
Bugfixes, public API, CMS error pages, BMC sponsor link
2026-04-27 22:41:02 +02:00
Paul Nothaft 46bc894d91 docs: add Buy Me a Coffee badge + Support section
Adds a yellow Buy Me a Coffee badge to the header alongside the existing
License/Docker/Node/React badges, plus a small "Support the Project"
section above Acknowledgments with the standard BMC button image. Also
adds a link in the inline nav row at the top of the README so first-time
visitors can find it.

Link: https://buymeacoffee.com/theluap

Lightweight, opt-in support — explicitly notes that starring, sharing,
filing good bug reports, and opening PRs are equally welcome ways to
help if money isn't in the budget.
2026-04-27 22:38:00 +02:00
Paul Nothaft 038e84cae7 fix: dedupe parallel admin 401 redirects to /admin/login
Visiting /admin/dashboard while logged out caused a navigation storm:
the dashboard fires ~7 /api/admin/* queries on mount, each returns 401,
each axios interceptor call did `window.location.href = '/admin/login'`.

The path-based guard `currentPath.includes('/admin/login')` reads
`location.pathname` *synchronously* — but `location.href = …` is async,
so all 7 parallel handlers saw the still-old pathname and each fired a
fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user
saw a flicker storm. Same shape would bite any admin page that fans out
queries on mount.

Add a module-level `adminLoginRedirectPending` flag set the moment we
kick off the first redirect; subsequent 401s in the same tick see it
and skip. Single navigation, clean transition to login.

Smoke spec 10-admin-redirect-loop locks the regression in by sampling
the URL across 5 ticks — if any tick lands somewhere other than
/admin/login, the spec fails.
2026-04-27 22:38:00 +02:00
Paul Nothaft 2eead52319 fix: theme picker buttons no longer submit the parent form (#326)
Every <button> inside ThemeCustomizerEnhanced was bare — no `type`
attribute, defaulting to `type="submit"`. Inside CreateEventPage's
<form onSubmit={handleSubmit}>, that turned every theme/layout/header/
divider/control/colour-mode/CSS-template click into a form submission.

When the form was empty, validation killed the submit silently — that
showed up earlier as #317.2 ("theme picker unclickable").

When the form was filled (event_name set, etc.), validation passed,
`createMutation.mutate(payload)` ran, and the user was navigated to a
freshly-created event they never asked for — #326's reported symptom.

Fix: add `type="button"` to all 9 unmarked <button>s in the customizer.
Also covered by smoke spec 09-create-event-no-instant-submit which fills
the form, clicks Modern Masonry, and asserts the URL stays on
/admin/events/new and the events count is unchanged.
2026-04-27 22:38:00 +02:00
Paul Nothaft 808b15bafb feat: public v1 API + token management + OpenAPI docs (#322)
Adds a long-lived bearer-token mechanism + scoped REST surface designed
for n8n-style automation: create a gallery, upload photos, fetch the
share URL — all via documented HTTPS endpoints instead of poking at the
admin UI's internal routes.

API
- Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK,
  last_used/expires/revoked timestamps).
- New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`,
  resolves to the owner admin user, attaches `req.admin` so existing
  permission decorators (events.create etc.) still work. Token-level
  scope check (read/write/admin) layers on top as defence in depth —
  a leaked read-only token cannot mutate even if its owner is super_admin.
- adminApiTokens route exposes list/create/revoke for admins (cookie-
  authed). Plaintext token is returned exactly once on creation.
- v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id,
  POST /events/:id/photos (multipart, single file), GET
  /events/:id/share-link. Each endpoint annotated with @openapi JSDoc.

Documentation
- swagger-jsdoc + swagger-ui-express produce a live spec at
  /api/openapi.json and a Swagger UI at /api/docs (admin-gated).
- backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml}
  to the repo so the spec is versioned.
- scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and
  copies it into the picpeak-docs Nextra site at app/api/. Writes only,
  never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass).

Frontend
- New Settings → API Tokens tab: generate, list, revoke. Plaintext
  tokens are shown once with a copy-to-clipboard control.
2026-04-27 22:38:00 +02:00
Paul Nothaft be6cb28c80 feat: optional customer phone field gated by global toggle (#322)
Adds a `customer_phone` column on events plus an `event_phone_field_enabled`
admin setting (default off) that surfaces the input in the create-event
and event-detail forms. Designed for downstream automation tooling — once
exposed via the upcoming public API, n8n / similar can pick it up to
deliver gallery links over WhatsApp, SMS, etc.

- Migration 080 adds the column + seeds the setting as false. Existing
  deployments see no UI change unless the admin opts in via
  Settings → Events.
- Backend strips the field server-side when the toggle is off (defence
  in depth against form bypass).
- Frontend renders the input only when the public-settings flag is true;
  always optional even then.
- publicSettings + EventSettings types extended; CreateEventPage and
  EventDetailsPage wired to read the toggle and submit the value.
2026-04-27 22:38:00 +02:00
Paul Nothaft 4f77905b87 feat: customisable 404 + gallery-not-found pages via CMS (#324)
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.

Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
  with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
  migration on existing deployments. Null falls back to the global
  branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
  clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
  storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.

Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
  the standard branded shell (logo precedence: page → branding → bundled
  default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
  identifier + infoError archived/missing) into a single
  CMSContentBlock("gallery-not-found"), so admins can edit one source
  of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
  control per page; falls back to the page's own English title in the
  page list when no `legal.<slug>` translation is registered.
2026-04-27 22:38:00 +02:00
Paul Nothaft b63a8774c4 fix: theme-preset match loop ignores extra fields like logoUrl (#323)
The "which preset does this saved theme match?" loop in BrandingPage and
CreateEventPage was doing a full JSON.stringify equality on preset.config
vs the loaded theme. The previous #323 logo-preservation work means the
saved theme legitimately carries a `logoUrl` (and any other fields the
parent maintains), so the equality check would never match and the
preset summary fell back to "Custom Theme" / Classic Grid even when the
saved theme was structurally Dark Modern, etc.

Compare only on the preset's own keys instead. Surfaced by the new
smoke spec 07-branding-default-on-create-event which would otherwise
pass green against the broken state.
2026-04-27 22:38:00 +02:00
Paul Nothaft 793e410554 fix: floor password_changed_at when comparing against JWT iat
JWT `iat` has 1-second resolution; `password_changed_at` is stored with
sub-second precision. The previous comparison rejected tokens whose iat
fell in the same wall-clock second as a password change — e.g. a token
issued by an immediate re-login after a password reset, or by any
script-driven flow that resets and logs in in quick succession. Floor
the stored timestamp to whole seconds before comparing.

Caught while wiring up the local E2E suite: the seeder needed a
"set password_changed_at 10 s in the past" hack to avoid this race;
with the fix in place that hack is gone and the suite is naturally
deterministic.
2026-04-27 22:38:00 +02:00
Paul Nothaft 8d0fb8e157 chore: expose pid + uptime on /health for crash-detection monitors
Adds `pid` and `uptime` fields to the /health response so external monitors
(and the local E2E watchdog) can detect a silent process restart between
two checks — e.g. an unhandled rejection that crashes Node and Docker
quietly relaunches the container.

Also adds .gitignore patterns for a local-only E2E suite that lives in
tests/e2e/local/ on individual machines and is never pushed.
2026-04-27 22:38:00 +02:00
Paul Nothaft 822be9a9b2 fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321)
#323-A — Branding colour changes weren't persisting unless "Apply changes
immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was
gating its `onChange` callback on `isPreviewMode`, but the parent
BrandingPage already gates global `setTheme()` on its own copy of that
flag — so the customizer's gate was double-gating and silently dropped
the new values from the parent state that Save reads from. Always
propagate `onChange`; let parents decide what's "live". Removed the now
no-op `isPreviewMode` prop and dropped the unused passers.

#323-B — Default theme set in Branding wasn't applied to new events.
CreateEventPage only inherited the event-type's recommended preset, with
'default' falling back to Classic Grid. Now reads `settings.theme_config`
on first load and uses it as the form's starting theme; the event-type
effect skips the generic 'default' so the Branding default sticks for
event types like "Other".

#321 — Visitors saw four sequential render states when opening a gallery
(full-page "Loading Gallery" → "publicly accessible — loading photos"
card → skeleton grid → real gallery). Extracted the skeleton into a
shared <GallerySkeleton/> and used it for both GalleryView's photos-
loading state and GalleryPage's gallery-info-loading + public-auto-login
phases. The "publicly accessible" interstitial is gone. Net: one
continuous skeleton from URL open until real photos render.
2026-04-27 22:38:00 +02:00
Paul Nothaft 63a6bfebce Merge pull request #320 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.29.1-beta.0
2026-04-26 22:52:44 +02:00
github-actions[bot] 3d5759738f chore(beta): release 3.29.1-beta.0 2026-04-26 20:52:07 +00:00
Paul Nothaft 2f2f405d9b Merge pull request #319 from the-luap/feat/prezip-and-photo-replace
fix: discussion #317 issues and #318 archive crash
2026-04-26 22:51:46 +02:00
Paul Nothaft 6cfff6f6a6 fix: address bugs and feature requests from discussion #317
- Share link: display and copy now use the absolute URL built from the
  current origin instead of the relative path stored in events.share_link.
  Added a Copy Link button to the events list (inline + dropdown).
- Detect dev tools default: event creation now reads the global
  enable_devtools_protection app setting instead of always falling back to
  the column default; admins who disable it globally get new events with
  it disabled too.
- Require password default: added a global "Require password by default"
  setting (event_default_require_password, default true), exposed via
  Settings -> Events. Create-event form initialises from it.
- Filter bar: added gallery_show_filter_bar setting and hide the search/
  sort row in the public gallery when off, or when the gallery has zero
  photos (fixes the empty-state UX from the screenshot).
- Theme picker unclickable on Create Event: memoised availableEventTypes
  so its identity is stable. The "auto-apply event-type recommended
  preset" effect was firing on every render due to the unstable array
  reference and silently overwriting the user's preset selection ~1ms
  after each click.
- Branding logo disappearing on theme change: handlePresetChange and
  handleThemeChange no longer wipe the existing logoUrl when a preset
  config (which carries no logoUrl) is applied; handleSave falls back to
  brandingSettings.logo_url. themeMutation now invalidates the
  admin-settings and public-settings caches so saved theme changes appear
  immediately.
2026-04-26 22:48:59 +02:00
Paul Nothaft e4b0f961b7 fix: prevent backend crash on archive when admin_email is null (#318)
Archiving an event with no admin_email queued an email_queue row with
recipient_email=null, violating the NOT NULL constraint. The error was
thrown inside the output.on('close') callback (detached from the caller),
becoming an unhandled rejection that crashed Node and dropped admin
sessions on bulk archive.

- Skip queueEmail when event.admin_email is null/empty (admin_email has
  been nullable since migration 073).
- Wrap the close handler in try/catch so any post-archive failure logs
  instead of crashing the process.
2026-04-26 22:15:00 +02:00
Paul Nothaft c0989796e4 Merge pull request #315 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.29.0-beta.0
2026-04-23 16:59:00 +02:00
github-actions[bot] 82adcd1f71 chore(beta): release 3.29.0-beta.0 2026-04-23 14:54:21 +00:00
Paul Nothaft d3f1206816 Merge pull request #314 from the-luap/feat/prezip-and-photo-replace
feat: pre-zip download all and photo replacement by name (#312, #313)
2026-04-23 16:49:59 +02:00
Paul Nothaft e18afd3e6b feat: pre-zip download all and photo replacement by name (#312, #313)
Pre-zip downloads:
- Generate ZIP in background after photo mutations (upload/delete/watermark change)
- Serve cached zip with Content-Length for instant downloads and native progress bar
- Falls back to on-the-fly streaming when no cache exists yet
- Frontend uses browser-native download when zip is ready (no blob buffering)
- New downloadZipService with debounced regeneration and in-memory locking

Photo replacement:
- Admin upload form gets "Replace existing photos with same name" checkbox
- Matches by original_filename (case-insensitive) within the same event
- Preserves photo ID, position, feedback, category, and visibility
- Updates file, thumbnail, dimensions, EXIF capture date on replacement
- Ambiguous matches (multiple photos with same name) skip replacement with warning
- New photoReplacementService with findReplacementCandidate and replacePhoto
2026-04-23 16:49:31 +02:00
Paul Nothaft 4353acebf9 Merge pull request #311 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.3-beta.0
2026-04-13 13:01:08 +02:00
github-actions[bot] 89f86b9fe4 chore(beta): release 3.28.3-beta.0 2026-04-13 05:30:51 +00:00
Paul Nothaft ceb2a09f48 Merge pull request #310 from the-luap/fix/welcome-message-and-guest-thumbnails
fix: revert /api prefix in adminPhotos.js to avoid double-prefix (#307)
2026-04-13 07:30:28 +02:00
Paul Nothaft 094276d3cc fix: revert /api prefix in adminPhotos.js to avoid double-prefix
AdminPhotoGrid uses AdminAuthenticatedImage which fetches via Axios
(baseURL: /api), so the backend URL must not include /api — Axios
adds it. The adminGuests.js /api prefix is correct because its
consumer (AuthenticatedImage) uses fetch() with buildResourceUrl().
2026-04-13 07:30:08 +02:00
Paul Nothaft 59b56ed3d7 Merge pull request #309 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.2-beta.0
2026-04-12 21:01:11 +02:00
github-actions[bot] 0a5b07de5d chore(beta): release 3.28.2-beta.0 2026-04-12 18:58:40 +00:00
Paul Nothaft b05c36ac81 Merge pull request #308 from the-luap/fix/welcome-message-and-guest-thumbnails
fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
2026-04-12 20:58:24 +02:00
Paul Nothaft 9323befdd9 fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
- Render welcome_message in gallery view for all non-fullpage layouts
  (grid, masonry, carousel, timeline, mosaic) as a centered banner
- Add /api prefix to thumbnail/photo URLs in adminGuests.js and
  adminPhotos.js so they route correctly through Nginx proxy
2026-04-12 20:58:02 +02:00
Paul Nothaft 61142c0d0e Merge pull request #305 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.1-beta.0
2026-04-12 10:06:43 +02:00
github-actions[bot] 623ab72916 chore(beta): release 3.28.1-beta.0 2026-04-12 08:06:08 +00:00
Paul Nothaft 3716ff5085 Merge pull request #304 from the-luap/fix/gallery-sort-direction-and-feedback-visibility
fix: apply sort direction in gallery and respect show_feedback_to_guests (#302, #303)
2026-04-12 10:05:52 +02:00
Paul Nothaft dffe057772 fix: apply sort direction in gallery view and respect show_feedback_to_guests (#302, #303)
- Gallery now respects the configured sort direction (asc/desc) from
  default_photo_sort setting instead of using hard-coded directions
- Photos endpoint zeroes out feedback fields (like_count, favorite_count,
  average_rating, comment_count, has_feedback) when show_feedback_to_guests
  is disabled, while still showing data to admin/client users
2026-04-12 10:05:24 +02:00
Paul Nothaft 3319a304ce Merge pull request #301 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.0-beta.0
2026-04-11 23:24:59 +02:00
github-actions[bot] c303dd51e8 chore(beta): release 3.28.0-beta.0 2026-04-11 21:24:41 +00:00
Paul Nothaft b1dfbe4c2f Merge pull request #300 from the-luap/feat/cookie-secure-auto
feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
2026-04-11 23:24:24 +02:00
Paul Nothaft 54badefc51 Merge pull request #299 from the-luap/fix/guest-masonry-lightbox
fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
2026-04-11 23:24:02 +02:00
Paul Nothaft 15a8ab41fd feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
Adds a third value for the COOKIE_SECURE environment variable that
decides the cookie Secure flag per-request based on req.secure. This
unblocks a common self-hosted setup where the same PicPeak deployment
is reachable over both HTTPS (via reverse proxy) and plain HTTP (e.g.
LAN access at http://192.168.x.x:3001).

Behavior

  unset  - legacy default: follows NODE_ENV (production=true, dev=false)
  true   - always set Secure (unchanged)
  false  - never set Secure (unchanged)
  auto   - NEW: use req.secure per request. In practice this means
           Secure on HTTPS requests (when X-Forwarded-Proto: https
           reaches Express via a trusted proxy) and no Secure flag
           on plain HTTP requests.

The existing trust proxy config (`app.set('trust proxy',
'loopback, linklocal, uniquelocal')` in server.js) means
X-Forwarded-Proto is honored when forwarded from local/private-network
proxies, which covers Docker network setups and most self-hosted
deployments behind NPM, Traefik, or Caddy.

auto is strictly opt-in. The default behavior is unchanged, so existing
users see no difference. A follow-up release can consider promoting
auto to the default after real-world feedback.

Also fixed (latent bug, benefits everyone)

Cookie clear operations (clearAdminAuthCookie, clearGalleryAuthCookies)
previously wrote the same `secure` attribute as the set path. When a
cookie was set with Secure=true over HTTPS and the clear request came
over HTTP (or vice versa under auto mode), some browsers would reject
the Set-Cookie delete header, leaving the cookie in place. Browsers
match cookies by (name, domain, path) for deletion and don't care about
Secure, so the new buildClearCookieOptions() helper simply omits the
secure attribute.

Implementation

- secureCookie string is replaced by secureCookieMode which can hold
  true, false, or 'auto'.
- New resolveSecureFlag(res) returns the boolean for a specific
  response, delegating to res.req.secure when in auto mode.
- buildCookieBaseOptions and buildCookieOptionsWithExpiry now take res
  and pass it through.
- New buildClearCookieOptions() deliberately omits `secure`.
- setAdminAuthCookie / setGalleryAuthCookies / clearAdminAuthCookie /
  clearGalleryAuthCookies all updated to thread res where needed.
  Public signatures unchanged — every caller already has res in scope.

Testing

Verified against a real Express instance inside the backend container
with trust proxy configured, covering:

  - (unset) + NODE_ENV=production -> secure: true (legacy)
  - (unset) + NODE_ENV=development -> secure: false (legacy)
  - COOKIE_SECURE=true + req.secure=false -> secure: true (literal wins)
  - COOKIE_SECURE=false + req.secure=true -> secure: false (literal wins)
  - COOKIE_SECURE=auto + X-Forwarded-Proto: https -> secure: true
  - COOKIE_SECURE=auto + plain HTTP -> secure: false
  - clearCookie always omits the secure attribute

Documentation

Added a COOKIE_SECURE block to both .env.example files (root for
docker-compose, backend/.env.example for native install) explaining the
four values, when to use auto, and the two requirements (proxy must
forward X-Forwarded-Proto, proxy IP must be in the trust list). Also
documented COOKIE_SAMESITE and COOKIE_DOMAIN alongside, which were
previously undocumented.
2026-04-11 22:41:15 +02:00
Paul Nothaft 77f07e9329 fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
Fixes two bugs reported on #292 after 3.27.0-beta.0 shipped:

1. Masonry grid showed no visual feedback after liking a photo.
   MasonryGalleryLayout's Like button had no liked-state plumbing —
   the Heart icon was a static <Heart> regardless of whether the user
   had liked the photo.

2. PhotoLightbox (fullscreen view) silently failed to like photos in
   guest identity mode. submitLike() and submitRating() never called
   ensureIdentity() before firing the API request, so the first
   interaction from a fresh session hit a 401 from the server instead
   of opening the name prompt.

Root causes:

1. MasonryGalleryLayout was missing the 'liked' state pattern that
   GridGalleryLayout already uses (likedPhotoIds Set in the parent,
   passed down as a `liked` prop, updated via onLikeSuccess callback).
   The bug was invisible in simple mode (no personal state) but
   surfaced immediately in guest mode where each guest expects to see
   confirmation of their own action.

2. PhotoLightbox's submit handlers were written before the guest
   identity context existed and only checked the legacy
   require_name_email flag. They were never updated when guest mode
   landed.

Also fixed: z-index conflict where the GuestNamePromptModal (z-50)
was sitting at the same level as PhotoLightbox (z-50), so when the
prompt opened over the lightbox, the fullscreen image intercepted
pointer events and the modal's Continue button was unclickable.
Bumped both guest modals to z-[60].

Changes:

- MasonryGalleryLayout.tsx
  - MasonryPhotoProps gains `liked?: boolean` + `onLikeSuccess?: () => void`.
  - Like button: red bg + filled white Heart icon when liked; aria-label
    toggles between "Like photo"/"Unlike photo"; aria-pressed mirrors state.
  - onClick wires onLikeSuccess() for optimistic UI in both guest-mode
    and simple-mode branches plus the FeedbackIdentityModal onSubmit path.
  - Parent layout holds `likedPhotoIds: Set<number>` and passes it to
    each MasonryPhoto (matches the GridGalleryLayout pattern).

- PhotoLightbox.tsx
  - Consumes useGuestIdentityOptional(); new `isGuestMode` flag.
  - submitLike() and submitRating() get a guest-mode branch that calls
    ensureIdentity() first and submits without body guest_name/email
    (server reads from the verified token).
  - Optimistic UI updates happen after successful submit in guest mode.

- GuestNamePromptModal.tsx, GuestRecoveryModal.tsx
  - z-50 → z-[60] so they render above PhotoLightbox.

Verified end-to-end against local Docker with Playwright MCP on event
168 (Masonry Columns Test layout):

- Fresh session, click Like in Masonry grid → name prompt opens, register,
  feedback persists with guest_id, Heart button turns red with
  aria-pressed and "Unlike photo" label. Subsequent likes on other
  photos also show red state. DB confirms feedback rows.

- Fresh session, open photo in lightbox BEFORE registering → click Like,
  the name prompt correctly opens on top of the lightbox, register,
  feedback persists. Rate 4 stars → works, average 4.0 (1) displayed
  in lightbox, ★ badge appears on toggle-feedback button, grid cell
  shows "1 likes" + "Rating: 4.0" indicators after closing lightbox.

- Backend DB: gallery_guests row created, photo_feedback rows have
  correct guest_id, server reads name from verified token (body values
  ignored).

Out of scope (documented in audit, not reported by the user, no
regression from guest mode): Mosaic/Carousel/Timeline have partial
optimistic-UI issues unrelated to this report; they pre-date guest
mode and behave the same in simple mode. Leaving alone per scope
discipline.
2026-04-11 14:42:28 +02:00
Paul Nothaft 72c0c2d18e Merge pull request #297 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.27.0-beta.0
2026-04-11 09:27:58 +02:00
github-actions[bot] 95d8bc4065 chore(beta): release 3.27.0-beta.0 2026-04-11 06:29:51 +00:00
Paul Nothaft 3856ba25bb Merge pull request #295 from the-luap/feat/guest-selections
feat: guest selections with per-person identity (#292)
2026-04-11 08:27:31 +02:00
Paul Nothaft 9e1ba4f851 Merge pull request #296 from the-luap/release-please--branches--beta
chore(beta): release 3.26.2-beta.0
2026-04-11 08:27:08 +02:00
github-actions[bot] b0efd32f7a chore(beta): release 3.26.2-beta.0 2026-04-11 06:26:39 +00:00
Paul Nothaft 9ed8a2b199 Merge pull request #294 from the-luap/fix/admin-photo-feedback-filters
fix: admin photo feedback filters have no effect (#293)
2026-04-11 08:26:19 +02:00
Paul Nothaft ad4e5a7506 feat: guest selections with per-person identity (#292)
Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).

New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
  'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.

Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
  on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
  req.guest.identifier takes precedence — per-person rate limits and
  per-person deduplication.

Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
  codes tables; identity_mode column + check constraint; nullable
  guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
  reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
  interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
  interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
  AdminGuestsList component.

Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
  groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
  pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.

Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
  6-digit code via the existing emailProcessor, POST /guest/verify
  exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
  ?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.

Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
  inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
  Timeline/Premium layouts.

Backwards compatibility
- Existing events default to 'simple' after migration; behavior
  unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
  the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
  guest_identifier) so per-guest counts are accurate without touching
  legacy rows.

Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
  sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
  displays thumbnail grid with badges; aggregate view sorts by picker
  count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
  Alice count = 4.
2026-04-11 07:48:23 +02:00
Paul Nothaft d4b4dc628f fix: wire admin photo feedback filters into grid query (#293)
The Has Likes / Has Favorites / Has Comments checkboxes in the admin
Event > Photos tab updated local state but never affected the visible
photo grid, because the feedbackFilters state was only wired to the
export menu and the backend /admin/photos/:eventId/photos endpoint had
no support for these params.

Fixes:
- backend/src/routes/adminPhotos.js: extend GET /:eventId/photos to
  accept has_likes, has_favorites, has_comments, min_rating, and logic
  (AND/OR) query params and apply them via where-clause groups using
  the existing denormalized like_count/favorite_count/comment_count/
  average_rating columns.
- frontend/src/services/photos.service.ts: add hasLikes, hasFavorites,
  hasComments, minRating, logic to the PhotoFilters interface and
  append them as query params in getEventPhotos.
- frontend/src/pages/admin/EventDetailsPage.tsx: merge feedbackFilters
  into combinedPhotoFilters (via useMemo) and key the admin-event-photos
  query on it, so toggling any checkbox refetches with the new params.

Verified end-to-end against local Docker: seeded event with a known
feedback distribution and confirmed
- Has Likes → 4 photos
- Has Favorites → 3 photos
- Likes AND Favorites → 1 photo
- Likes OR Favorites → 6 photos
- Has Comments → 2 photos
- network requests carry the exact query params
2026-04-11 07:46:32 +02:00
Paul Nothaft fe46e4268d Merge pull request #288 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.26.1-beta.0
2026-04-09 16:40:53 +02:00
github-actions[bot] 1f3b9c6712 chore(beta): release 3.26.1-beta.0 2026-04-09 14:27:12 +00:00
Paul Nothaft 5295516b67 Merge pull request #290 from the-luap/docs/fix-filesystem-gallery-docs
docs: clarify file system photo import requires existing event (#269)
2026-04-09 16:26:51 +02:00
Paul Nothaft ee0baafc59 docs: clarify file system photo import requires existing event (#269)
The "Method 2: File System" section in SIMPLE_SETUP.md implied you
could create a gallery by just copying files to the storage directory.
In reality, the event must exist in the database first — the file
watcher only adds photos to existing events.

Rewritten to clarify the prerequisite and explain how the file watcher
works (2s stability delay, supported formats, auto-thumbnailing).
2026-04-09 16:26:39 +02:00
Paul Nothaft c63bc47089 Merge pull request #289 from the-luap/fix/password-change-regular-modal
fix: apply password change fix to regular modal + longer toast delay (#263)
2026-04-09 16:06:13 +02:00
Paul Nothaft 147dc28440 fix: apply password change redirect fix to regular modal too (#263)
The redirect loop fix only covered MandatoryPasswordChangeModal.
The regular PasswordChangeModal (profile settings) had the same
issue — onSuccess updated React state but didn't handle the new
JWT cookie, causing the same redirect loop.

Also increase redirect delay from 500ms to 2000ms in both modals
so the success toast is visible before the page reloads.
2026-04-09 16:05:53 +02:00
Paul Nothaft c031b1e863 Merge pull request #287 from the-luap/fix/password-change-iat-timing
fix: resolve JWT iat timing issue in password change (#263)
2026-04-09 16:00:25 +02:00
Paul Nothaft b1d16670d5 fix: set JWT iat after password_changed_at to prevent token rejection (#263)
The new token issued after password change had iat (integer seconds)
that was <= password_changed_at (millisecond precision), causing the
auth middleware's "iat < passwordChangedTime" check to reject it
immediately. Set iat explicitly to 1 second after password_changed_at.

E2E tested: login → mandatory password change → dashboard loads
successfully with no redirect loop and no 401 errors.
2026-04-09 16:00:03 +02:00
Paul Nothaft ba1f010166 Merge pull request #285 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.26.0-beta.0
2026-04-09 15:40:18 +02:00
github-actions[bot] ad64005a80 chore(beta): release 3.26.0-beta.0 2026-04-09 13:11:41 +00:00
Paul Nothaft 8805fa53e6 Merge pull request #286 from the-luap/feat/photo-sort-by-capture-date
feat: sort photos by capture date with configurable default sort (#283)
2026-04-09 15:11:19 +02:00
Paul Nothaft 633d4a0f30 feat: sort photos by capture date with configurable default sort (#283)
Add per-event default photo sort setting with 6 options:
- Upload Date (Newest/Oldest First)
- Date Taken (Newest/Oldest First) — uses EXIF captured_at
- Filename (A-Z / Z-A)

Backend:
- Migration 077 adds default_photo_sort column to events table
- Event create/update handlers accept and validate the setting
- Gallery info endpoint returns default_photo_sort for frontend

Frontend:
- "Date Taken" added to gallery sort dropdown (alongside Date, Name,
  Size, Rating)
- Gallery initializes with event's default sort instead of hardcoded
  "date"
- "Default Photo Sort" dropdown in event create and edit forms
- Photos without EXIF dates fall back to upload date

i18n: All 5 locales (EN, DE, NL, PT, RU) updated with sort labels.

Closes #283
2026-04-09 15:10:51 +02:00
Paul Nothaft b23c51b386 Merge pull request #284 from the-luap/fix/password-change-loop-and-filewatcher
fix: resolve password change redirect loop (#263) and file watcher crash (#269)
2026-04-09 13:54:54 +02:00
Paul Nothaft 835bdf5abb fix: resolve password change redirect loop and file watcher crash
#263: The mandatory password change modal updated React state before
the browser stored the new JWT cookie, causing a race condition where
the auth context checked the session with the old (invalidated) token.
Replace the state update with a full page redirect to /admin/dashboard
after a brief delay, ensuring the new cookie is applied cleanly.

#269: The file watcher service imported isVideoMimeType from
fileSecurityUtils where it doesn't exist. The function is exported
from videoProcessor. Fix the import path.

Closes #269
2026-04-09 13:54:23 +02:00
Paul Nothaft a1b63de251 Merge pull request #279 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.25.0-beta.0
2026-04-08 12:03:29 +02:00
github-actions[bot] 97b1ae5b03 chore(beta): release 3.25.0-beta.0 2026-04-08 09:51:27 +00:00
Paul Nothaft dc98206737 Merge pull request #278 from the-luap/feat/draft-mode-branding-improvements
feat: draft mode, admin branding, and workflow improvements
2026-04-08 11:51:07 +02:00
Paul Nothaft 40332a71db feat: draft mode, admin branding, and workflow improvements
Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table

Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token

Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)

OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
  branding settings

Editable Client Email:
- Customer email is now editable after event creation in edit mode

Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
  from global branding configuration

Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
2026-04-08 11:42:38 +02:00
Paul Nothaft 125cd0d003 Merge pull request #274 from the-luap/security/fix-dep-vulnerabilities
security: fix 20 dependency vulnerabilities
2026-04-08 09:04:19 +02:00
Paul Nothaft 83868ffe2f security: fix 20 dependency vulnerabilities (11 error, 7 warning, 2 note)
Update direct dependencies and overrides to address GitHub code scanning alerts:

- handlebars 4.7.8 -> 4.7.9 (5 CVEs: RCE, DoS, XSS, code execution)
- nodemailer 7.0.12 -> 7.0.13 (SMTP command injection)
- tar 7.5.11 -> 7.5.13 override (symlink/hardlink path traversal)
- fast-xml-parser >=5.3.8 -> >=5.5.10 override (entity expansion bypass)
- brace-expansion >=5.0.0 -> >=5.0.5 override (DoS via zero step)
- path-to-regexp 0.1.12 -> 0.1.13 override (ReDoS via malformed URL params)
- lodash 4.17.23 -> >=4.18.1 override (prototype pollution, code execution)

The picomatch CVEs are in npm's own node_modules inside the Docker image
and do not affect application code.
2026-04-08 09:04:06 +02:00
Paul Nothaft 9ddd50f7e4 Merge pull request #266 from the-luap/security/pin-axios-version
security: pin axios to 1.14.0 — supply chain attack prevention
2026-04-05 18:40:47 +02:00
Paul Nothaft bec36fc99f security: pin axios to 1.14.0 to prevent supply chain attack
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper (plain-crypto-js) attributed to North Korean threat
actor UNC1069/Sapphire Sleet. The malicious versions have been removed
from npm but our ^1.12.2 range could have pulled 1.14.1 on next install.

Pin to exact version 1.14.0 (latest safe release) in both frontend and
backend package.json and lock files to prevent any future resolution to
compromised versions.

References:
- https://github.com/axios/axios/issues/10604
- https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/
2026-04-05 18:40:24 +02:00
Paul Nothaft ea50488e99 Merge pull request #265 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.24.1-beta.0
2026-04-05 18:36:13 +02:00
github-actions[bot] 8614c2232c chore(beta): release 3.24.1-beta.0 2026-04-05 16:34:26 +00:00
Paul Nothaft 07fc5e6519 Merge pull request #264 from the-luap/fix/password-change-redirect-loop
fix: resolve redirect loop after mandatory password change (#263)
2026-04-05 18:34:07 +02:00
Paul Nothaft 3c8d344ddd fix: resolve redirect loop after mandatory password change (#263)
After changing password, the backend sets password_changed_at which
invalidates the old JWT token. But the frontend still holds the old
token in the HttpOnly cookie, so the next session check returns 401,
triggering an infinite redirect loop between /admin/login and
/admin/dashboard.

Fix: issue a new JWT token cookie after successful password change
so the session remains valid without requiring re-login.
2026-04-05 18:33:46 +02:00
Paul Nothaft edf8bd54af Merge pull request #262 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.24.0-beta.0
2026-04-05 18:24:36 +02:00
github-actions[bot] 2b7c9b0138 chore(beta): release 3.24.0-beta.0 2026-04-04 21:33:29 +00:00
Paul Nothaft aef9b4ed7f Merge pull request #261 from the-luap/feat/beta-theme-thumbnail-warning
feat: warn about low thumbnail resolution with beta themes
2026-04-04 23:33:12 +02:00
Paul Nothaft ee3f6ae13b feat: warn about low thumbnail resolution when selecting beta themes
Beta themes (Gallery Premium, Gallery Story) display thumbnails at
400-800px, but the default thumbnail size is 300x300px, causing visible
pixelation. Show an amber warning banner with a link to Thumbnail
Settings when a beta layout is active and thumbnails are below 500px.

Warning appears both in the preset selector and the layout selector
sections of the theme customizer.
2026-04-04 23:32:49 +02:00
Paul Nothaft 5025a42bf7 Merge pull request #259 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.23.0-beta.0
2026-04-04 22:28:44 +02:00
github-actions[bot] 0a7a89045b chore(beta): release 3.23.0-beta.0 2026-04-04 20:13:49 +00:00
Paul Nothaft ddefd3a95e Merge pull request #260 from the-luap/fix/backend-dockerfile-npm-version
fix: pin npm to v10 in backend Dockerfile
2026-04-04 22:13:32 +02:00
Paul Nothaft 978e4473b5 fix: pin npm upgrade to v10 in backend Dockerfile
npm@latest resolves to v11 which has a broken promise-retry dependency
on Node 22 Alpine, causing Docker builds to fail. Pin to npm@10 which
stays compatible with the Node 22 base image.
2026-04-04 22:13:11 +02:00
Paul Nothaft 8c5996e4ec Merge pull request #258 from the-luap/feat/email-template-translations
feat: multilingual email templates with translations table
2026-04-04 17:43:29 +02:00
Paul Nothaft f50d7c0c51 feat: multilingual email templates with translations table
Replace column-based email template languages (subject_en/subject_de) with
a normalized email_template_translations table where each language is a row.
This allows adding new languages without schema changes.

- Add migration 075 to create email_template_translations table, migrate
  existing EN/DE data, and seed NL/PT/RU for customer-facing templates
- Update processTemplate() to query translations table with fallback chain
  (requested lang -> en -> first available), with legacy column fallback
- Restructure admin email API to return/accept translations object format
- Update frontend EmailConfigPage with dynamic 5-language tabs, translation
  count badges, and copy-from-language feature for empty translations
- Add Dutch to default language dropdown in general settings
- Add Dutch to clientAccessI18n and password security messages in emails
- Expand email domain detection for NL/BE/BR/PT/RU domains
- Add email i18n keys (copyFrom, noTranslation, etc.) across all 5 locales
2026-04-04 17:43:01 +02:00
Paul Nothaft 4ce8dd297a Merge pull request #257 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.22.0-beta.0
2026-03-26 08:04:59 +01:00
github-actions[bot] 85a4eb90fd chore(beta): release 3.22.0-beta.0 2026-03-25 21:36:31 +00:00
Paul Nothaft e32da68cbd Merge pull request #256 from the-luap/feat/add-dutch-locale
feat: add Dutch locale and fix missing translation keys
2026-03-25 22:36:15 +01:00
Paul Nothaft b54a80d251 feat: add Dutch (nl) locale and fix missing translation keys across all locales
Add complete Dutch translation (2054 keys) with Netherlands flag in the
language selector. Also synchronize all existing locales so every language
has the same set of keys: added 29 missing keys to EN/RU/PT and 95 missing
keys to DE (moderation, analytics, CSS templates, backup, events).
2026-03-25 22:35:57 +01:00
github-actions[bot] 2ac6c51fe5 chore(beta): release 3.21.1-beta.0 (#255)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-22 12:43:55 +01:00
Paul Nothaft 23cd9cb680 fix: address Shannon security assessment findings (37 vulnerabilities) (#254)
Remediate 4 Critical, 18 High, 10 Medium, and 5 Low vulnerabilities
identified in the Shannon security assessment (2026-03-20).

Critical fixes:
- Command injection via rsync SSH key path (INJ-VULN-01)
- Self-escalation to super_admin role (AUTHZ-VULN-11)
- Invite super_admin backdoor (AUTHZ-VULN-12)
- Handlebars SSTI in email templates (INJ-VULN-05)

Authentication hardening:
- Rate limit on share-link login (AUTH-VULN-01)
- X-Forwarded-For spoofing bypass (AUTH-VULN-02)
- reCAPTCHA fails closed when misconfigured (AUTH-VULN-03)
- Token revocation on admin/gallery logout (AUTH-VULN-04/05)
- Cookie Secure flag defaults true in production (AUTH-VULN-06)
- Remove JWT from admin login response body (AUTH-VULN-07)
- Timing-safe gallery slug validation (AUTH-VULN-09)
- Account lockout fails closed on DB error (AUTH-VULN-12)
- Session endpoint checks token revocation

Path traversal & file access:
- checksums endpoint path containment (INJ-VULN-03)
- manifest validate path containment (INJ-VULN-04)

XSS prevention:
- Block SVG data URIs in CSS sanitizer (XSS-VULN-01)
- Email preview iframe sandbox (XSS-VULN-02)
- SSR branding HTML escaping (XSS-VULN-03)
- User-Agent sanitization in feedback (XSS-VULN-04)

Authorization (IDOR):
- Event ownership middleware for all admin routes
- Cross-admin user profile read restriction (AUTHZ-VULN-10)

SSRF & infrastructure:
- Private IP validation for SMTP, S3, rsync hosts
- Replace inline JWT with standard adminAuth middleware
- CSRF Content-Type enforcement on mutating API endpoints
- CSP headers in nginx location blocks

Token revocation fix:
- Remove overly broad orWhere clause that invalidated all future tokens
- Allow empty-body POST requests (logout) in CSRF middleware

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-22 12:40:01 +01:00
github-actions[bot] a63f1a8dd9 chore(beta): release 3.21.0-beta.0 (#253)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-18 10:59:14 +01:00
Paul Nothaft 954a0118ba fix: wrap test email with standard email template (#252)
Use wrapEmailHtml() for the test email so it matches the look of all
other emails sent by the platform (logo, footer, etc.).

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:49:45 +01:00
Paul Nothaft ee46088985 feat: add per-gallery thumbnail scale setting (#172) (#251)
Add a thumbnailScale field (xs/sm/md/lg/xl) to gallery layout settings
that adjusts column counts for Grid, Masonry (columns mode), and Mosaic
layouts. Each scale maps to a column offset applied on top of the
layout's base columns, letting photographers control photo density.

- Add thumbnailScale to GalleryLayoutSettings type
- Apply scale offset in Grid, Masonry, and Mosaic layout components
- Add thumbnail scale dropdown to admin theme customizer
- Conditionally show dropdown only for applicable layouts
- Safelist dynamic grid-cols classes in Tailwind config
- Add i18n keys for EN, DE, PT, RU locales

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:44:07 +01:00
github-actions[bot] 3742d71535 chore(beta): release 3.20.1-beta.0 (#250)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-17 17:18:56 +01:00
Paul Nothaft 486239aeb9 fix: address beta feedback - gallery layout fixes, Russian locale, email logo (#249)
- Add Russian (Русский) to admin settings language dropdown
- Fix Premium layout hero using thumbnail instead of hero_url
- Hide "Uncategorized" section header in Story layout for uncategorized photos
- Add PhotoLightbox to Story layout so photo clicks open full-screen view
- Use full-res images in StoryPhotoCard instead of thumbnails
- Defer public gallery auto-login text until settings/locale are loaded
- Add validation and debug logging for email logo URL construction

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-17 17:16:50 +01:00
Paul Nothaft 2c5ae6fbb9 Merge pull request #248 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.20.0-beta.0
2026-03-17 13:14:43 +01:00
github-actions[bot] f9889a93fb chore(beta): release 3.20.0-beta.0 2026-03-17 12:05:29 +00:00
Paul Nothaft 4a93e4e8cb Merge pull request #247 from the-luap/feat/photo-visibility-client-access
feat: photo visibility control with client access (#172)
2026-03-17 13:05:12 +01:00
Paul Nothaft e1b6e43e52 feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to
review and hide photos before the gallery is shared with guests.

Backend:
- Migration 074: add visibility column to photos, client_access_enabled/
  client_password_hash/client_share_token to events
- Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN
- Gallery photo list filters hidden photos for guests, shows all for clients
- Visibility toggle endpoints (single + bulk) for client access level
- Admin event CRUD supports client access fields
- Email template includes client access link + PIN (EN/DE/RU/PT)

Frontend:
- ClientAccessPage: PIN entry form at /gallery/:slug/client-access
- GalleryView: client mode banner, visibility counter, toggle controls
- GridGalleryLayout: eye/eye-off overlay per photo for clients
- AdminPhotoGrid: visibility badge, bulk Hide/Show buttons
- EventDetailsPage: Client Access settings section (toggle, PIN, link)
- CreateEventPage: client access toggle + PIN in event creation form
- GalleryAuthContext: accessLevel/isClient/clientLogin support
- New complete pt-BR locale (pt.json) with all translations
- Client access i18n keys for EN, DE, RU, PT
2026-03-17 13:04:41 +01:00
Paul Nothaft 999c66dbbf Merge pull request #244 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.2-beta.0
2026-03-16 22:37:45 +01:00
github-actions[bot] f5997892c4 chore(beta): release 3.19.2-beta.0 2026-03-16 21:35:09 +00:00
Paul Nothaft 7ca96315e2 Merge pull request #243 from the-luap/fix/security-session-invalidation
fix(security): token invalidation on password change, session timeout enforcement
2026-03-16 22:34:53 +01:00
Paul Nothaft f3622396e7 fix(security): invalidate tokens on password change, enforce session timeout, fix role update
- Set password_changed_at when changing password via adminAuth route so
  existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
  token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
  frontend updateUser service (fixes silent role update failures)

Resolves GHSA-rqg3-47p5-vgwg
2026-03-16 22:34:32 +01:00
Paul Nothaft 56cf60c570 Merge pull request #242 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.1-beta.0
2026-03-16 22:32:17 +01:00
github-actions[bot] 2618415aa1 chore(beta): release 3.19.1-beta.0 2026-03-16 21:23:14 +00:00
Paul Nothaft dfae2c2bc6 Merge pull request #241 from the-luap/fix/external-media-dimensions-and-email-colors
fix: external media dimensions, theme race condition, email color customization
2026-03-16 22:22:58 +01:00
Paul Nothaft bbeedd1888 fix: resolve external media dimensions, gallery theme race condition, and add email color customization
- Fix dimension repair for external media by using photoResolver instead of hardcoded paths
- Extract photo dimensions via Sharp during external media import
- Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition
- Pass event welcome_message to Story layout footer for per-event customization
- Add email_primary_color/email_secondary_color settings with admin UI color pickers
- Add i18n keys for email branding in all 4 locales (en, de, ru, pt)
2026-03-16 22:22:36 +01:00
Paul Nothaft 201965b4b1 Merge pull request #240 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.0-beta.0
2026-03-16 20:25:20 +01:00
github-actions[bot] 1468c459ba chore(beta): release 3.19.0-beta.0 2026-03-16 16:24:01 +00:00
Paul Nothaft 088de43f09 Merge pull request #239 from the-luap/feat/photo-cap-and-portuguese-locale
feat: add photo cap per event and Portuguese locale
2026-03-16 17:23:37 +01:00
Paul Nothaft 1fa222e9c4 feat: add photo cap per event and Portuguese (pt-BR) locale
- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
2026-03-16 17:22:54 +01:00
Paul Nothaft 6aceb40595 Merge pull request #238 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.2-beta.0
2026-03-16 16:26:55 +01:00
github-actions[bot] 431a82eca1 chore(beta): release 3.18.2-beta.0 2026-03-16 15:26:11 +00:00
Paul Nothaft 85a07fcca7 Merge pull request #237 from the-luap/fix/security-dep-updates
fix: resolve code scanning security alerts (multer, tar, Node 22)
2026-03-16 16:25:52 +01:00
Paul Nothaft 1f524f2358 fix: update dependencies to resolve code scanning security alerts
- Upgrade multer to 2.1.1 (CVE-2026-3520, DoS via malformed requests)
- Update tar override to >=7.5.11 (CVE-2026-31802, CVE-2026-29786)
- Upgrade Node base image from 20-alpine to 22-alpine to fix npm
  bundled tar/minimatch CVEs in the Docker image
2026-03-16 16:25:29 +01:00
Paul Nothaft 48a025b915 Merge pull request #236 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.1-beta.0
2026-03-16 15:02:04 +01:00
github-actions[bot] c652ae0ead chore(beta): release 3.18.1-beta.0 2026-03-16 14:01:13 +00:00
Paul Nothaft 9a6d2e8e3a Merge pull request #235 from the-luap/fix/email-preview-wrapper
fix: wrap email preview with full styled header/footer template
2026-03-16 15:00:56 +01:00
Paul Nothaft fc0911acf8 fix: wrap email preview with full styled header/footer template
The email template preview modal was showing only raw body HTML without
the styled wrapper (green header bar, logo, footer with company name)
that processTemplate() applies when sending. This made preview not match
what recipients actually receive.

Extract wrapEmailHtml() from processTemplate() and reuse it in the
preview endpoint. Also fix logo URL to use FRONTEND_URL consistently.

Closes #229
2026-03-16 15:00:37 +01:00
Paul Nothaft f77802325a Merge pull request #234 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.0-beta.0
2026-03-16 10:22:14 +01:00
github-actions[bot] 74c9a5fbcd chore(beta): release 3.18.0-beta.0 2026-03-16 08:38:17 +00:00
Paul Nothaft 703c03fbee Merge pull request #233 from the-luap/feat/visual-email-editor
feat: visual WYSIWYG email template editor
2026-03-16 09:37:57 +01:00
Paul Nothaft 6f95b8c26c feat: register Russian locale and add to language selector
Import ru.json translations in i18n config and add Russian with flag
to the language selector dropdown.
2026-03-16 09:35:03 +01:00
Paul Nothaft 7250c427b9 fix: shorten Save button label on email template editor
Change "Save Changes" to "Save" for cleaner toolbar layout.
2026-03-16 09:29:24 +01:00
Paul Nothaft 04a7ea80f9 feat: add visual WYSIWYG email template editor (#229)
Replace raw HTML textarea with TipTap-based rich text editor for email
templates. Includes formatting toolbar, variable insertion dropdown,
source/visual toggle, and dark mode support. Add Mailhog service to
docker-compose for local email testing.
2026-03-15 22:05:27 +01:00
Paul Nothaft c0a5cd56c8 Merge pull request #232 from the-luap/i18n/ru-missing-keys
i18n: add missing Russian translations for thumbnails and photo dimensions
2026-03-15 20:12:22 +01:00
Paul Nothaft 908ab08815 Merge beta to resolve conflicts for PR #232 2026-03-15 20:02:27 +01:00
Paul Nothaft 52ab609597 i18n: add missing Russian translations for thumbnails and photo dimensions
Adds 38 missing keys for settings.thumbnails and settings.photoDimensions
that were added after the initial Russian localization PR (#216).
2026-03-15 19:48:41 +01:00
Paul Nothaft fafcfbf4e6 Merge pull request #216 from Ih0rd/russian-localization
basic Russian localization
2026-03-15 19:47:25 +01:00
Paul Nothaft f07602553c Merge pull request #228 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.2-beta.0
2026-03-11 21:54:58 +01:00
Paul Nothaft 56f497c5f1 Merge pull request #227 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m29s
Build and Push Docker Images / build-frontend (push) Failing after 3m40s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.1
2026-03-11 21:54:39 +01:00
github-actions[bot] d2663bff81 chore(beta): release 3.17.2-beta.0 2026-03-11 19:48:06 +00:00
github-actions[bot] b52cf1f741 chore(main): release 2.6.1 2026-03-11 19:48:05 +00:00
Paul Nothaft 308e086263 Merge pull request #226 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:51 +01:00
Paul Nothaft 7f7736282f Merge pull request #225 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:41 +01:00
Paul Nothaft 67b0f32456 fix: update security policy with proper contact email and private reporting
- Replace placeholder security@example.com with info@picpeak.app
- Add GitHub Private Vulnerability Reporting links
- Update supported versions table to 2.x.x

Closes #223
2026-03-11 20:21:32 +01:00
Paul Nothaft 25b40c03b0 Merge pull request #224 from the-luap/release/beta-to-main
Merge beta into main
2026-03-11 20:19:10 +01:00
Paul Nothaft 28793bba68 Merge main into beta for release/beta-to-main 2026-03-11 20:12:52 +01:00
Paul Nothaft 4ae91142f8 Merge pull request #222 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m23s
Build and Push Docker Images / build-frontend (push) Failing after 3m18s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.0
2026-03-11 12:01:51 +01:00
github-actions[bot] c92879fbd3 chore(main): release 2.6.0 2026-03-11 10:57:06 +00:00
Paul Nothaft a0bb080586 Merge pull request #221 from the-luap/fix/video-upload-select-all-dimensions
fix: video upload, select all, and dimension repair (#203, #220, #180)
2026-03-11 11:56:38 +01:00
Paul Nothaft fc75bcdfc3 fix: video upload media type, select all, and dimension repair (#203, #220, #180)
- Fix admin video upload missing media_type/mime_type and video processing (#203)
- Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220)
- Add photo dimension repair endpoint and admin UI (#180)
- Add E2E tests for all three fixes
2026-03-11 11:50:43 +01:00
Paul Nothaft 9877f63aed Merge pull request #219 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.1-beta.0
2026-03-08 15:47:06 +01:00
github-actions[bot] 0c98c6b453 chore(beta): release 3.17.1-beta.0 2026-03-08 14:42:28 +00:00
Paul Nothaft 831ea6a3bc Merge pull request #218 from the-luap/fix/optional-email-event-creation
fix: respect optional email settings in event creation
2026-03-08 15:42:14 +01:00
Paul Nothaft 9c44a0ebfa fix: respect optional email settings in event creation (#217)
When admin/customer emails were configured as optional in Settings >
Event Creation, the backend still rejected empty values because:

1. express-validator .optional() only skips undefined, not empty strings
   — changed to .optional({ values: 'falsy' }) so "" is treated as
   absent
2. DB columns host_email and admin_email had NOT NULL constraints
   — added migration to make them nullable
3. Email queue insert crashed on null recipient_email
   — skip queuing when no customer email is provided
2026-03-08 15:36:38 +01:00
Ih0rd a840ad4594 basic Russian localization 2026-03-06 06:17:30 +03:00
Paul Nothaft 08ac238d0a Merge pull request #215 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.0-beta.0
2026-03-05 22:22:58 +01:00
github-actions[bot] 7d967a47ae chore(beta): release 3.17.0-beta.0 2026-03-05 21:21:48 +00:00
Paul Nothaft 9b7495e005 Merge pull request #214 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:21:29 +01:00
Paul Nothaft e1ad4219a5 Merge pull request #212 from the-luap/revert-210-feat/configurable-upload-batch-size
Revert "feat: configurable upload batch size for reverse proxy compatibility"
2026-03-05 22:16:43 +01:00
Paul Nothaft cc4503ad28 Revert "feat: configurable upload batch size for reverse proxy compatibility" 2026-03-05 22:16:28 +01:00
Paul Nothaft 424336340b Merge pull request #210 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:14:41 +01:00
Paul Nothaft a8308a5c02 Merge pull request #209 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.16.0-beta.0
2026-03-05 22:14:28 +01:00
Paul Nothaft 02a46e083d feat: add configurable upload batch size for reverse proxy compatibility (#208)
Users behind Cloudflare Tunnel and other reverse proxies cannot upload
batches >100MB. The upload chunking previously used a hardcoded 500MB
limit. This adds a configurable `max_upload_batch_size_mb` setting
(default 95MB) to the admin General settings, leaving headroom below
Cloudflare's 100MB limit.
2026-03-05 22:12:37 +01:00
github-actions[bot] 98fd6dd8e1 chore(beta): release 3.16.0-beta.0 2026-03-05 20:38:56 +00:00
Paul Nothaft 3a30fea862 Merge pull request #207 from the-luap/fix/github-issues-194-197-main
feat: add thumbnail settings UI to admin panel
2026-03-05 21:38:41 +01:00
Paul Nothaft 7d6d2f5688 feat: add thumbnail settings UI to admin settings page (#206)
Add a new "Thumbnails" tab in the admin settings page allowing users to
configure thumbnail dimensions, quality, format, and fit mode from the UI.
Also fix backend route column name mismatch (key/value → setting_key/setting_value)
that caused a 500 error, and add a button to regenerate all thumbnails.
2026-03-04 22:55:14 +01:00
Paul Nothaft b5074e4e46 Merge pull request #205 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.3-beta.0
2026-03-02 23:18:27 +01:00
github-actions[bot] a1d941f049 chore(beta): release 3.15.3-beta.0 2026-03-02 22:18:06 +00:00
Paul Nothaft 80171713e0 Merge pull request #204 from the-luap/fix/github-issues-194-197-main
fix: issue #203 file type validation + security CVE fixes
2026-03-02 23:17:50 +01:00
Paul Nothaft c0301dcbf4 Merge branch 'beta' into fix/github-issues-194-197-main 2026-03-02 23:15:37 +01:00
Paul Nothaft cbecb9323c fix(security): resolve Docker image CVEs for code scanning alerts
- Upgrade nginx base from 1.27-alpine to 1.28-alpine (Alpine 3.23, OpenSSL 3.5.5)
- Upgrade npm to latest in backend production stage to fix tar, minimatch, brace-expansion CVEs
- Add brace-expansion and minimatch overrides for app-level transitive deps
- Remove incompatible body-parser v2 override (breaks Express 4 JSON parsing)
- Remove npm upgrade from builder stages (npm 11 breaks npm ci with existing lockfile)
2026-03-02 23:06:15 +01:00
Paul Nothaft 4272618b3f fix(security): resolve all npm audit vulnerabilities
Frontend (6 → 0 vulnerabilities):
- axios: update to fix DoS via __proto__ key in mergeConfig (CVE-2026-25639)
- swiper: update to fix prototype pollution (critical)
- rollup: update to fix arbitrary file write via path traversal
- minimatch: update to fix multiple ReDoS vulnerabilities
- ajv: update to fix ReDoS with $data option
- markdown-it: update to fix ReDoS

Backend (32 → 0 vulnerabilities):
- multer: update to fix DoS via incomplete cleanup and resource exhaustion
- minimatch: update to fix multiple ReDoS vulnerabilities
- Add npm overrides for transitive dependencies:
  - fast-xml-parser >=5.3.8 (fixes XSS, DoS, stack overflow via AWS SDK)
  - qs >=6.14.2 (fixes arrayLimit bypass DoS via Express)
  - tar >=7.5.8 (fixes path traversal and hardlink attacks via sqlite3)

Docker:
- Pin nginx base image to 1.27-alpine in Dockerfile.prod
- Update security comments in backend Dockerfile
- Existing apk upgrade --no-cache ensures OpenSSL/libexpat CVEs are
  patched at build time (OpenSSL 3.5.5, Alpine 3.23.3)
2026-03-02 10:36:47 +01:00
Paul Nothaft fe07a148f1 fix: respect allowed_file_types setting for upload validation (#203)
The "Allowed File Types" admin setting was stored in the database but
never actually read during upload validation. Both frontend and backend
used hardcoded MIME type lists, causing video uploads (e.g. MP4) to be
rejected even when explicitly added to the setting.

Changes:
- Add getAllowedMimeTypes() to uploadSettings service that reads the
  general_allowed_file_types DB setting and converts extensions to MIME types
- Backend admin upload route now resolves allowed types from settings
  before multer processes files (via resolveAllowedTypes middleware)
- Backend gallery upload route uses dynamic allowed types from settings
- Expose allowed_file_types in public settings API for gallery clients
- Frontend PhotoUpload and UserPhotoUpload components now derive allowed
  MIME types from settings instead of hardcoded image-only lists
- Add shared fileTypes.ts utility for extension-to-MIME conversion

Closes #203
2026-03-01 14:36:34 +01:00
Paul Nothaft 0ec4190e2e Merge pull request #201 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m17s
Build and Push Docker Images / build-frontend (push) Failing after 3m17s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.5.1
2026-02-23 20:10:21 +01:00
Paul Nothaft 0ec3787150 Merge pull request #200 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.2-beta.0
2026-02-23 20:10:12 +01:00
github-actions[bot] 59faf73f04 chore(main): release 2.5.1 2026-02-22 21:37:40 +00:00
github-actions[bot] 3e0c4fd73e chore(beta): release 3.15.2-beta.0 2026-02-22 21:37:26 +00:00
Paul Nothaft 33af088560 Merge pull request #199 from the-luap/fix/github-issues-194-197-main
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:21 +01:00
Paul Nothaft 5ea4ef3cf3 Merge pull request #198 from the-luap/fix/github-issues-194-197
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:11 +01:00
Paul Nothaft 33483cf32d fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:34:44 +01:00
Paul Nothaft cd00bc13d4 fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:27:21 +01:00
Paul Nothaft 26ec9666b9 Merge pull request #193 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m47s
Build and Push Docker Images / build-frontend (push) Failing after 3m40s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.5.0
2026-02-21 20:52:30 +01:00
github-actions[bot] f672c1daa6 chore(main): release 2.5.0 2026-02-21 19:48:43 +00:00
Paul Nothaft 5f1f0f253d Merge pull request #192 from the-luap/release/beta-to-main
Release v3.15.1: Merge beta to main
2026-02-21 20:48:01 +01:00
Paul Nothaft 888c4ab209 Merge main into beta for release/beta-to-main
Resolved conflicts in CHANGELOG.md, backend/package.json, and
frontend/package.json. Version set to 3.15.1.
2026-02-21 20:43:46 +01:00
Paul Nothaft 551d9cc66f Merge pull request #191 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.1-beta.0
2026-02-21 20:32:31 +01:00
github-actions[bot] 9045402c9a chore(beta): release 3.15.1-beta.0 2026-02-21 19:31:37 +00:00
Paul Nothaft 0817443e79 Merge pull request #190 from the-luap/feat/new-features
fix: docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example (#189)
2026-02-21 20:31:20 +01:00
Paul Nothaft a4c624802b fix: update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example (#189)
- Replace deprecated docker-compose (v1) with docker compose (v2) in README
- Add missing ADMIN_PASSWORD to .env.example so new users don't get a
  blank-string warning and can actually log in after first setup
2026-02-21 08:28:49 +01:00
Paul Nothaft 79cf4100a1 Merge pull request #188 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.0-beta.0
2026-02-17 20:48:38 +01:00
github-actions[bot] fe9486e5fa chore(beta): release 3.15.0-beta.0 2026-02-17 19:47:41 +00:00
Paul Nothaft bcf2745ab6 Merge pull request #187 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, security hardening, and bug fixes
2026-02-17 20:47:25 +01:00
Paul Nothaft c4f16eb76c fix: events without expiration date incorrectly shown as expired
When expires_at is null (no expiration), the status logic defaulted
days to 0, causing all non-expiring events to display as "Expired".
Now returns "Active" immediately when there is no expiration date.
2026-02-17 15:48:57 +01:00
Paul Nothaft 5925ea8406 Merge pull request #186 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.14.0-beta.0
2026-02-17 15:38:34 +01:00
github-actions[bot] 6613f1b088 chore(beta): release 3.14.0-beta.0 2026-02-17 14:38:14 +00:00
Paul Nothaft 3ea9d5b121 Merge pull request #185 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, and security hardening
2026-02-17 15:37:56 +01:00
Paul Nothaft 0891be197f feat: show original filename in admin UI (#184)
Surface the existing original_filename from the database in the admin
photo grid hover overlay and photo viewer sidebar, so photographers can
correlate uploaded images with their Lightroom/disk originals. Only shown
when it differs from the system-generated filename. Gallery guests remain
unaffected.
2026-02-17 15:30:12 +01:00
Paul Nothaft 2b25d81144 security: comprehensive hardening across frontend, backend, and infrastructure
- Disable production source maps and hide nginx version
- Reduce JSON body limit from 10gb to 50mb (uploads use multer, not JSON)
- Strip database info and error details from health endpoint
- Mask reCAPTCHA secret key in admin settings API responses
- Whitelist sort/order query parameters in events and photos endpoints
- Stop reflecting arbitrary origins in static file CORS headers
- Align nginx security headers with backend Helmet CSP, remove deprecated X-XSS-Protection
- Strip EXIF metadata from generated thumbnails and hero images
- Bind postgres/redis dev ports to localhost in docker-compose configs
- Add safeExec utility (spawn with shell:false) to prevent command injection
- Convert all exec/execAsync calls in backup, restore, and database backup
  services to use safe spawn-based helpers
2026-02-16 22:33:20 +01:00
Paul Nothaft 50c09904a9 feat: add update instructions dialog, email notifications, and capture date sorting
- Add Update Instructions Dialog with environment-specific commands (Docker/Git/Standalone)
- Add email notification settings for new version alerts
- Add "Sort by Capture Date" option using EXIF metadata extraction
- Fix E2E tests by loading environment variables via dotenv
- Add test-images/ and backend/*.db to .gitignore

Closes #181
2026-02-16 16:23:57 +01:00
Paul Nothaft 7aa37b2447 Merge pull request #183 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.13.1-beta.0
2026-02-15 22:53:50 +01:00
github-actions[bot] d239857d9a chore(beta): release 3.13.1-beta.0 2026-02-15 21:49:34 +00:00
Paul Nothaft 3974ba5de5 Merge pull request #182 from the-luap/feat/new-features
fix: restore aspect-ratio layouts and improve hero image quality (#180)
2026-02-15 22:49:20 +01:00
Paul Nothaft 5cef7fdd18 fix: restore aspect-ratio layouts and improve hero image quality (#180)
- Fix masonry/mosaic layout regression where tiles displayed uniform heights
  instead of respecting image aspect ratios. Changed from fixed 150-500px
  height constraints to dynamic constraints based on column width.

- Add hero image optimization pipeline generating 1920x1080 images for
  full-width hero sections instead of using low-quality thumbnails.

- New /hero/:photoId endpoint serves optimized hero images with watermark
  support and automatic generation/caching.

- Add hero_url field to photos API response for frontend consumption.

- Migration 069 adds hero_path column to photos table.
2026-02-15 22:43:18 +01:00
Paul Nothaft 092f007ed3 Merge pull request #179 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.13.0-beta.0
2026-02-07 00:36:35 +01:00
github-actions[bot] edf3a43950 chore(beta): release 3.13.0-beta.0 2026-02-06 23:35:55 +00:00
Paul Nothaft 45d78c0dce Merge pull request #178 from the-luap/feat/new-features
feat: improve hero image UX and live preview (#163, #158)
2026-02-07 00:35:37 +01:00
Paul Nothaft d63f67a2af feat: improve hero image UX and live preview (#163, #158)
- Update hero photo help text to mention category override capability
- Add hint in category manager about default hero photo fallback
- Add placeholder text in gallery preview for hero section
- Ensure live preview updates correctly for header/divider style changes
2026-02-07 00:29:25 +01:00
Paul Nothaft ad00eae251 Merge pull request #177 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.12.0-beta.0
2026-02-06 23:31:17 +01:00
github-actions[bot] 2c35543e73 chore(beta): release 3.12.0-beta.0 2026-02-06 22:29:57 +00:00
Paul Nothaft 7c75736719 Merge pull request #176 from the-luap/feat/new-features
Feat/new features
2026-02-06 23:29:39 +01:00
Paul Nothaft 9c2a0d272a feat: add admin dark mode and SEO/robots.txt settings
Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage

SEO Settings:
- Add robots.txt configuration in Settings > SEO tab
- Block AI crawlers (GPTBot, ChatGPT-User, etc.) with toggle
- Custom robots.txt rules management
- Add RobotsMetaTags component for gallery pages
- Backend service for dynamic robots.txt generation
- Database migration for SEO settings storage

UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
2026-02-06 23:26:01 +01:00
Paul Nothaft 4912e2bccf fix: improve ghost button visibility in admin dark mode
Update ghost button variant to use proper dark mode colors:
- Add dark:hover:bg-neutral-700 for hover state
- Add dark:text-neutral-300 for better icon/text visibility
- Fixes too-dark edit and view gallery buttons in Events table
2026-02-06 23:23:34 +01:00
Paul Nothaft f8c8abd70b fix: resolve mixed light/dark mode styling in admin UI (#175)
- Update .card class to use explicit Tailwind colors instead of CSS
  variables, preventing gallery theme from affecting admin UI
- Add .card-themed and .input-themed classes for gallery components
  that need to use theme CSS variables
- Add dark mode support to CardHeader and CardFooter components
- Update .input class to use explicit colors for proper light/dark mode
- Update dark mode selectors for consistency (.dark .class)
2026-02-06 23:13:15 +01:00
Paul Nothaft 7726adeff0 Merge pull request #174 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.11.0-beta.0
2026-02-06 21:46:42 +01:00
github-actions[bot] e05fd64760 chore(beta): release 3.11.0-beta.0 2026-02-06 20:46:15 +00:00
Paul Nothaft 4280444d70 Merge pull request #173 from the-luap/feat/new-features
feat: gallery layouts, hero customization, event types, and UX improvements (#146, #155-163, #170, #171)
2026-02-06 21:45:59 +01:00
Paul Nothaft 6491184402 chore: add dependencies for Gallery Premium/Story layouts
Add missing npm packages required for new gallery layouts:
- yet-another-react-lightbox: lightbox component
- framer-motion: animations
- photoswipe: photo gallery
- swiper: carousel/slider
2026-02-06 21:43:09 +01:00
Paul Nothaft 171abb3161 fix: improve password validation errors and event list UX (#170, #171)
- Show specific failing password requirement instead of generic error
  when password validation fails on AcceptInvitePage (#170)
- Add inline Edit and View Gallery buttons to events table (#171)
- Make event table rows clickable to navigate to details (#171)
- Keep context menu for less common actions (Archive, Delete)
- Add responsive design: inline buttons hidden on mobile
2026-02-06 18:38:12 +01:00
Paul Nothaft e179def3cc feat: add Gallery Premium and Gallery Story layouts (Beta)
- Add Gallery Premium layout: elegant light theme with masonry grid,
  hero section, sticky navigation, and integrated lightbox
- Add Gallery Story layout: cinematic dark theme with scene-based
  sections, carousels, and gold accents
- Implement full-page layout support: bypass standard header/footer/
  sidebar for immersive experience
- Add logout button to both layouts for authenticated galleries
- Mark both layouts as (Beta) in theme editor and layout selectors
- Fix hero title color visibility in Gallery Premium layout
2026-02-06 18:03:47 +01:00
Paul Nothaft bc6c48bb24 fix: render minimal/none header styles, cap hero height, switch category hero images (#158, #162, #163)
- Add distinct rendering branches for minimal and none header styles in
  GalleryLayout (grid and non-grid), skipping the colored banner/wave
  divider for both
- Cap hero section height at 700px via max-h to prevent it dominating
  ultra-wide viewports
- Watch selectedCategoryId in GalleryView and swap the hero photo to
  the category's hero_photo_id when filtering, reverting to the event
  default when cleared
- Add minimal/none preview branches in GalleryPreview so the admin
  theme editor shows visually distinct previews for all four styles
- Remove unused AdminPhoto import that was blocking the build
- Add Playwright e2e tests covering all four header styles, hero max
  height, and category hero switching
2026-02-04 08:30:55 +01:00
Paul Nothaft 57845a5508 Merge pull request #167 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 4m15s
Build and Push Docker Images / summary (push) Waiting to run
2026-02-03 19:09:07 +01:00
github-actions[bot] 10ff6b118c chore(beta): release 3.10.1-beta.0 2026-02-03 16:25:51 +00:00
Paul Nothaft 2288309395 Merge pull request #166 from the-luap/feat/new-features
fix: sync header_style DB column with theme editor selections (#158)
2026-02-03 17:16:09 +01:00
Paul Nothaft a19e7c40a2 fix: sync header_style DB column with theme editor selections (#158)
The frontend never sent header_style/hero_divider_style as separate
fields when creating or updating events, so the database columns always
kept their default value of 'standard' — making the hero header
impossible to enable through the admin UI.

- Extract headerStyle/heroDividerStyle from theme config and include in
  create and update payloads (CreateEventPage, EventDetailsPage)
- Add backend fallback to extract values from color_theme JSON when not
  explicitly provided, ensuring older clients stay in sync
2026-02-03 17:12:56 +01:00
Paul Nothaft de56cd0dce Merge pull request #165 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m18s
Build and Push Docker Images / build-frontend (push) Failing after 3m31s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.10.0-beta.0
2026-02-03 15:59:42 +01:00
github-actions[bot] 8ddec6ed8b chore(beta): release 3.10.0-beta.0 2026-02-03 14:56:50 +00:00
Paul Nothaft d9e00dc0db Merge pull request #164 from the-luap/feat/new-features
feat: gallery layouts, hero customization, bulk categories & event types
2026-02-03 15:50:54 +01:00
Paul Nothaft 6c30e2c2ed feat: add category hero/cover photo selection (#163)
Wire up the hero_photo_id column on photo_categories that was added in
the migration but never connected. Backend routes now accept and persist
hero_photo_id on category create/update, a dedicated PUT /:id/hero
endpoint is added, and the gallery API returns hero_photo_id for each
category. Frontend EventCategoryManager shows a clickable thumbnail per
category that opens a photo picker modal. Includes EN/DE i18n keys.
2026-02-03 15:43:23 +01:00
Paul Nothaft 329d224846 fix: resolve code quality issues and add missing i18n keys (#162, #163)
Add missing i18n translations for hero image focal point picker in both
EN and DE locales. Fix lint errors across touched files: remove unused
imports/variables, replace raw buttons with shared Button component,
eliminate inline styles, extract duplicated backend validation, and
remove dead heroImagePosition type.
2026-02-03 10:53:13 +01:00
Paul Nothaft 734868abc2 feat: add hero image focal point picker with anchor positioning (#162)
Add interactive focal point picker for hero images, allowing precise
crop positioning via click or preset buttons (top/center/bottom).
Includes backend validation, migrations, and gallery rendering support.
2026-02-03 10:08:58 +01:00
Paul Nothaft f554f463b3 fix: hero header state and preview in admin theme editor (#158)
- Add hero header rendering to GalleryPreview component with divider styles
- Support event-specific header_style prop in GalleryLayout
- Pass header_style from event data to GalleryLayout in GalleryView
- Divider options now properly show/hide when switching header styles

This ensures the live preview accurately reflects hero header changes
and event-specific header styles are respected in the gallery view.
2026-02-02 23:09:36 +01:00
Paul Nothaft fa4c83812d fix: improve photo serving, category filters, and upload chunking (#155, #156, #161)
- Add try-catch and file existence check for photo path resolution (#161)
- Fix gallery categories to use photo_categories table instead of legacy type field (#156)
- Add byte-size-based chunking (500MB max) for uploads to prevent oversized batches (#155)
2026-02-02 22:55:48 +01:00
Paul Nothaft 8cc5685428 Merge pull request #160 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.9.0-beta.0
2026-02-01 22:59:20 +01:00
github-actions[bot] 7bf1e5c0f9 chore(beta): release 3.9.0-beta.0 2026-02-01 21:58:22 +00:00
Paul Nothaft 7037106bff Merge pull request #159 from the-luap/feat/new-features
feat: gallery layouts, bulk category editing, and hero header improvements
2026-02-01 22:58:08 +01:00
Paul Nothaft eca36c70a2 feat: add bulk category editing for photos (#157)
Add BulkCategoryModal component that allows selecting multiple photos
and moving them to a different category in one operation.
2026-02-01 22:48:08 +01:00
Paul Nothaft 7b8d8bd92b feat: decouple hero header from gallery layouts (#158)
- Add separate header_style setting (hero/standard/minimal/none) that can
  be combined with any layout type (grid/masonry/carousel/timeline/mosaic)
- Create HeroHeader and HeroDivider components for reusable hero section
- Add hero_divider_style setting (wave/straight/angle/curve/none)
- Add database migration for header_style and hero_divider_style columns
- Remove deprecated HeroGalleryLayout component
- Fix various TypeScript errors across the codebase:
  - Add missing type properties (css_template_id, updatedAt, justified settings)
  - Fix null handling for event_date and expires_at fields
  - Fix translation function calls and i18n config
  - Remove unused imports and variables
2026-02-01 22:44:28 +01:00
Paul Nothaft 397d33a95a fix: increase upload limit to 1GB and fix category filters (#155, #156)
- Increase nginx client_max_body_size from 100MB to 1GB for video support
- Fix admin photo category filtering to properly handle numeric category IDs
  from the photo_categories table, not just legacy 'individual'/'collage' types
- Add support for 'uncategorized' filter to show photos with no category
2026-02-01 21:07:44 +01:00
Paul Nothaft 08c2e4530e Merge pull request #154 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.8.0-beta.0
2026-01-30 08:35:15 +01:00
github-actions[bot] 9ec0e2e7c0 chore(beta): release 3.8.0-beta.0 2026-01-30 07:34:38 +00:00
Paul Nothaft aacfcd517e Merge pull request #153 from the-luap/feat/new-features
feat: improve gallery layouts with aspect-ratio-aware masonry and mosaic modes (#146)
2026-01-30 08:34:24 +01:00
Paul Nothaft 27ff51e7a1 fix: use photo dimensions for mosaic aspect ratios (#146)
Thumbnails are generated as 300x300 squares, so CSS Columns alone
couldn't show varied aspect ratios. Now using the photo's width/height
metadata with CSS aspect-ratio property to force correct proportions.
2026-01-30 08:23:58 +01:00
Paul Nothaft 821d3296ea fix: use CSS Columns for gap-free mosaic layout (#146)
Replaced CSS Grid with span rules approach with CSS Columns to eliminate
gaps and white spaces in the mosaic layout. Images now flow vertically
within columns, maintaining their natural aspect ratios without gaps.
2026-01-29 23:16:14 +01:00
Paul Nothaft 46ed1bc276 feat: add quilted layout, fix mosaic, and backfill photo dimensions (#146)
- Add migration to backfill width/height for existing photos without dimensions
- Replace justified masonry mode with quilted layout (mixed sizes based on aspect ratio)
- Rewrite mosaic layout to use proper CSS Grid with span rules
- Fix theme not being applied after gallery login
- Improve columns mode distribution using shortest-column algorithm
- Apply gallery theme regardless of authentication status
2026-01-29 23:09:12 +01:00
Paul Nothaft 8711f967a1 fix: use actual photo aspect ratios in masonry columns mode (#146)
Previously, the Pinterest-style columns mode assigned random heights to
photos, causing landscape images to be cropped into portrait slots.
Now the height is calculated based on the photo's actual aspect ratio
and the column width, preserving natural proportions.
2026-01-29 21:40:41 +01:00
Paul Nothaft 5c8aed5793 Merge pull request #151 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.7.0-beta.0
2026-01-28 22:54:59 +01:00
github-actions[bot] c40f34d3de chore(beta): release 3.7.0-beta.0 2026-01-28 21:54:14 +00:00
Paul Nothaft ef2ae00ff2 Merge pull request #150 from the-luap/feat/new-features
feat: Add justified layout modes and aspect-ratio-aware mosaic (#146)
2026-01-28 22:53:58 +01:00
Paul Nothaft 608bbd50e7 feat: add justified layout modes and aspect-ratio-aware mosaic (#146)
- Add Flickr justified-layout and react-photo-album as masonry mode options
- Implement aspect-ratio-aware mosaic layout that dynamically selects
  patterns based on photo orientations to minimize cropping
- Add 9 mosaic pattern types optimized for different orientation combinations
- Add theme customizer options for masonry mode selection (columns/rows/flickr/justified)
- Add i18n translations for new layout options
2026-01-28 22:31:34 +01:00
Paul Nothaft e3024e6ffd Merge pull request #148 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.6.0-beta.0
2026-01-27 11:47:16 +01:00
github-actions[bot] b4978c0869 chore(beta): release 3.6.0-beta.0 2026-01-27 10:45:56 +00:00
Paul Nothaft cd1d50474f Merge pull request #147 from the-luap/feat/new-features
feat: add justified/rows layout mode to masonry gallery (#146) + security fixes
2026-01-27 11:45:38 +01:00
Paul Nothaft 8097a0cb53 fix: update packages to fix security vulnerabilities
- react-router-dom 6.30.2 → 6.30.3 (XSS via Open Redirects)
- react-router 6.30.2 → 6.30.3
- @remix-run/router 1.23.1 → 1.23.2
- lodash 4.17.21 → 4.17.23 (Prototype Pollution)
2026-01-27 11:40:18 +01:00
Paul Nothaft e081b56a44 feat: add justified/rows layout mode to masonry gallery (#146)
Add Google Photos-style justified row layout as a mode within masonry:

- Add masonryMode setting: 'columns' (Pinterest) or 'rows' (Google Photos)
- Create justifiedLayoutCalculator utility for row-based layouts
- Extract and store image dimensions on upload for layout calculations
- Include width/height in gallery API response
- Add row height and last row behavior controls to theme customizer
- Support responsive container width detection with ResizeObserver

Photos in rows mode maintain their aspect ratios while filling
horizontal rows at a consistent height. The number of photos per
row is automatically calculated based on target row height and
photo dimensions.

Closes #146
2026-01-27 09:58:09 +01:00
Paul Nothaft c2309af3e0 Merge pull request #144 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.5.0-beta.0
2026-01-25 15:25:57 +01:00
github-actions[bot] 32fc939c7a chore(beta): release 3.5.0-beta.0 2026-01-25 14:24:28 +00:00
Paul Nothaft 4c081601e0 Merge pull request #143 from the-luap/feat/new-features
feat: per-event custom logos, customizable event types, and multiple bug fixes
2026-01-25 15:24:14 +01:00
Paul Nothaft 85170b883f feat: add per-event custom logo upload with bug fixes
Add event-level custom logo upload/delete endpoints and UI, allowing
per-event logos to override the global branding logo in gallery views.

Also fixes several bugs discovered during testing:
- fix: category_id 'individual' parsed as NaN causing photo upload failures
- fix: gallery auth race condition where photos query fired before token stored
- fix: gallery-photos query not invalidated after favorite/like mutations
- fix: e2e test race conditions with View Gallery button detachment
2026-01-23 22:01:02 +01:00
Paul Nothaft c018604e5d Merge pull request #142 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.4.0-beta.0
2026-01-22 14:03:45 +01:00
github-actions[bot] 9c8b5e9fd6 chore(beta): release 3.4.0-beta.0 2026-01-22 13:00:33 +00:00
Paul Nothaft 151e1bf50f Merge pull request #141 from the-luap/feat/new-features
feat: new features and bug fixes for beta release
2026-01-22 14:00:14 +01:00
Paul Nothaft c5a8ffc08c fix: handle null dates in dashboard and gallery pages
Add null checks for expires_at and event_date fields to prevent
TypeError when calling parseISO() on null values. This fixes crashes
that occurred after making event dates optional.

- AdminDashboard: skip events with null expires_at in expiring filter
- GalleryPage: handle null expires_at in expiration calculation
- GalleryView: make daysUntilExpiration nullable with explicit checks
- EventDetailsPage: return null from safeParseDate for null inputs
2026-01-22 13:54:23 +01:00
Paul Nothaft d4a15dbe74 fix: remove non-functional watermark toggle from Feature Toggles
The "Enable watermark on photos" checkbox in Settings > General > Feature
Toggles was not connected to any backend logic - it stored a setting that
was never read or used. The actual working watermark functionality exists
in Settings > Branding.

This removes the dead toggle to eliminate user confusion (fixes #140).
2026-01-22 13:54:23 +01:00
Paul Nothaft 0790a1ddad feat: add per-event hero logo customization options
Add configurable hero logo settings for individual events:
- Logo visibility toggle (show/hide in hero section)
- Logo size options (small, medium, large, xlarge)
- Logo position options (top, center, bottom)

Changes include:
- Database migration for hero_logo_visible, hero_logo_size, hero_logo_position fields
- Backend routes updated to handle new settings
- Frontend admin page with logo customization controls
- HeroGalleryLayout component with dynamic logo rendering
- i18n translations for EN and DE

Also updates .gitignore to exclude test files and artifacts.
2026-01-22 13:54:23 +01:00
Paul Nothaft f8881d5bd6 feat: add customizable event types with admin management
Implements GitHub issue #139 - allows users to create and manage custom
event types beyond the default presets (wedding, birthday, corporate, other).

Backend:
- Add event_types table migration with default system types
- Create eventTypeService for CRUD operations with legacy fallback
- Add adminEventTypes routes with full REST API
- Update event validation to use dynamic event types
- Update slug generation to use custom slug_prefix

Frontend:
- Add EventTypesPage with full CRUD admin interface
- Add eventTypes.service.ts API client
- Update CreateEventPage to fetch types dynamically
- Add Event Types navigation in admin sidebar
- Add i18n translations (EN/DE)

Backward compatible: existing galleries continue to work, legacy types
accepted even if database is empty via fallback mechanisms.
2026-01-22 13:54:23 +01:00
Paul Nothaft 6b3ead747b fix: resend gallery email fails for events without password
Added optional chaining when accessing req.body.password in the
resend-email endpoint to handle cases where req.body is undefined.
This prevented the "Cannot read properties of undefined" error.

Fixes #137
2026-01-22 13:54:00 +01:00
Paul Nothaft dadef81158 fix: event-specific custom CSS settings not being saved
The ThemeCustomizerEnhanced component stored customCss in a separate
local state that was never propagated to the parent component when
hideActions was true (used in both CreateEventPage and EventDetailsPage).

Changes:
- handleChange() now includes customCss when propagating theme changes
- CSS textarea onChange now propagates customCss to parent in preview mode
- handlePresetSelect() clears customCss when selecting a preset

Fixes #136
2026-01-22 13:54:00 +01:00
Paul Nothaft 644ea22b5f Merge pull request #135 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.3.0-beta.0
2026-01-21 17:06:49 +01:00
github-actions[bot] f4da354ae7 chore(beta): release 3.3.0-beta.0 2026-01-21 16:06:24 +00:00
Paul Nothaft a59f41463f Merge pull request #134 from the-luap/feat/optional-event-date-expiration-beta
feat: add original filename preservation and Lightroom export support
2026-01-21 17:06:06 +01:00
Paul Nothaft 9872ad3aef feat: add original filename preservation and Lightroom export support
Addresses GitHub issue #132 - enables filtering client feedback and
exporting filenames for use in Lightroom.

Changes:
- Add original_filename column to photos table via migration
- Store original filename during photo upload
- Fix export service column name mismatches (path, size_bytes, uploaded_at)
- Fix table name (photo_categories instead of categories)
- Fix toFixed() calls to handle string ratings from database

Export formats available:
- TXT with comma separator (for Lightroom Library Filter)
- CSV with full metadata
- JSON for automation
- XMP sidecar files (for Lightroom/Bridge/Capture One)
2026-01-21 16:47:13 +01:00
Paul Nothaft d0880ccb03 Merge pull request #131 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.5-beta.0
2026-01-18 15:38:03 +01:00
github-actions[bot] 237eeea5a6 chore(beta): release 3.2.5-beta.0 2026-01-18 14:35:30 +00:00
Paul Nothaft 41bf6ff884 Merge pull request #130 from the-luap/feat/optional-event-date-expiration-beta
fix: resolve admin invitation flow issues and improve STORAGE_PATH documentation
2026-01-18 15:35:15 +01:00
Paul Nothaft 991aa98f98 fix: correct invitation activation validation and add missing translations
- Fix password minimum length validation: frontend now correctly requires
  12 characters to match backend validation (was incorrectly checking for 8)
- Fix translation key references in AcceptInvitePage to use correct paths
  (e.g., acceptInvitation.errors.* instead of acceptInvitation.*)
- Add missing translations for both EN and DE:
  - contactAdminMessage
  - passwordsMatch
  - alreadyHaveAccount
  - signIn

Fixes #129
2026-01-18 15:00:38 +01:00
Paul Nothaft 86fa1046d5 fix: correct invitation email link URL path
The invitation email was generating links to /admin/accept-invite/{token}
but the frontend route is configured at /invite/{token}. This caused
invited users to see a blank page when clicking the email link.

Fixes #129
2026-01-18 12:55:56 +01:00
Paul Nothaft 3397807670 docs: emphasize importance of STORAGE_PATH in env example 2026-01-17 15:48:24 +01:00
Paul Nothaft cdda709886 fix: add STORAGE_PATH to production docker-compose
Ensures STORAGE_PATH environment variable is explicitly set in
production deployments to prevent path resolution issues when
serving thumbnails and other storage-related operations.
2026-01-17 15:48:15 +01:00
Paul Nothaft 023bb97e66 Merge pull request #128 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.4-beta.0
2026-01-17 15:08:48 +01:00
github-actions[bot] cf38305f28 chore(beta): release 3.2.4-beta.0 2026-01-17 14:08:09 +00:00
Paul Nothaft 0e3674b2b0 Merge pull request #127 from the-luap/feat/optional-event-date-expiration-beta
fix: correct storage path resolution in multiple files (#96)
2026-01-17 15:07:56 +01:00
Paul Nothaft 3ccb8154eb fix: correct storage path resolution in multiple files (#96)
Fixed inconsistent storage path fallbacks that caused 500 errors when
serving thumbnails. The paths were using '../../storage' (2 levels up)
instead of '../../../storage' (3 levels up) when STORAGE_PATH env var
is not set.

Affected files:
- backend/src/routes/gallery.js
- backend/src/services/photoService.js
- backend/src/services/eventService.js
2026-01-17 14:01:29 +01:00
Paul Nothaft b5ac18121d Merge pull request #126 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.3-beta.0
2026-01-16 15:23:29 +01:00
github-actions[bot] b613f8fbc7 chore(beta): release 3.2.3-beta.0 2026-01-16 14:19:26 +00:00
Paul Nothaft cacaffa5c3 Merge pull request #125 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button not visible in gallery (#113)
2026-01-16 15:19:09 +01:00
Paul Nothaft 691e3aba09 fix: add allow_user_uploads to gallery API responses
The gallery /photos and /info endpoints were not returning the
allow_user_uploads field, causing the upload button to never show
in the frontend since the value was always undefined/false.
2026-01-16 15:15:09 +01:00
Paul Nothaft 70a0caa11f Merge pull request #124 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.2-beta.0
2026-01-16 14:53:44 +01:00
github-actions[bot] e808e529cd chore(beta): release 3.2.2-beta.0 2026-01-16 13:53:31 +00:00
Paul Nothaft 05a5307e22 Merge pull request #123 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:53:17 +01:00
Paul Nothaft 2a2c23d116 fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:49:37 +01:00
Paul Nothaft a092d98523 Merge pull request #122 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.1-beta.0
2026-01-16 14:38:39 +01:00
github-actions[bot] b5f06af126 chore(beta): release 3.2.1-beta.0 2026-01-16 13:35:53 +00:00
Paul Nothaft 6cb43428d1 Merge pull request #121 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:35:36 +01:00
Paul Nothaft df7dbffbff fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:29:49 +01:00
Paul Nothaft 94421a6b12 Merge pull request #120 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.0-beta.0
2026-01-16 09:43:51 +01:00
github-actions[bot] 7805e89bfe chore(beta): release 3.2.0-beta.0 2026-01-16 08:43:36 +00:00
Paul Nothaft 3079eaa2e5 Merge pull request #119 from the-luap/feat/optional-event-date-expiration-beta
feat: add optional event date and expiration settings
2026-01-16 09:43:23 +01:00
Paul Nothaft 2151147f2d feat: add optional event date and expiration settings
Add global settings to make event_date and expiration optional when
creating galleries. This supports non-event use cases like portraits,
corporate shoots, etc.

New features:
- Settings toggles in Settings → Event Creation tab
- "Require event date" checkbox with warning about random URL identifiers
- "Require expiration date" checkbox with warning about manual archiving
- Galleries without date use random hex suffix in slug (e.g. portrait-smith-a1b2c3)
- Galleries without expiration never expire (stay active until archived)

Backend changes:
- New migration for settings and nullable columns
- Conditional validation based on settings
- Updated slug generation with random suffix fallback
- Updated expiration checker to skip null expires_at
- Updated gallery access control for null expiration

Frontend changes:
- New checkboxes in EventsTab with warnings
- Conditional event date field (shows optional label)
- No Expiration message when expiration disabled
- Updated types for nullable event_date and expires_at

Closes #118
2026-01-16 09:39:32 +01:00
Paul Nothaft 3e69579f5a docs: add API_URL environment variable to .env.example files
Document the API_URL environment variable that is used for constructing
URLs for assets (logos, images) in email notifications. Without this
setting, the system defaults to http://localhost:3001 which causes
broken images in production emails.

Added to both root and backend .env.example files with clear
documentation about its purpose and importance.
2026-01-16 09:39:32 +01:00
Paul Nothaft 808ed1d2f1 fix: checkbox and toggle settings not persisting after page refresh
PostgreSQL's json column type returns parsed values directly (boolean
false instead of string "false"). The backend code used a truthy check
which failed for boolean false values, causing null to be returned
instead of the actual false value.

Changed condition from `if (setting.setting_value)` to explicit null
check `if (setting.setting_value !== null && setting.setting_value !== undefined)`
and added handling for already-parsed json column values.

Fixes #117
2026-01-16 09:39:32 +01:00
Paul Nothaft b40e085d28 Merge pull request #116 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m18s
Build and Push Docker Images / build-frontend (push) Failing after 3m15s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.1.0-beta.0
2026-01-15 15:04:17 -05:00
github-actions[bot] d603567e21 chore(beta): release 3.1.0-beta.0 2026-01-15 20:03:57 +00:00
Paul Nothaft c6fdd38e84 Merge pull request #115 from the-luap/fix/codeql-v4-upgrade
feat: pre-generated watermarks and mobile upload button improvements
2026-01-15 15:03:25 -05:00
Paul Nothaft ae181cf92f fix: show upload button in mobile topbar instead of sidebar
The upload button was hidden in the sidebar on mobile devices, requiring
users to open the menu to find it. Now it appears directly in the topbar
for easy access on all screen sizes.

- Remove !isMobile condition from header upload button
- Add responsive text (short on mobile, full on desktop)
- Remove duplicate upload button from sidebar

Fixes #113
2026-01-15 21:00:17 +01:00
Paul Nothaft 1be974afbb feat: pre-generate watermarks for instant lightbox loading
Previously watermarks were applied on-the-fly when viewing photos in the
lightbox, causing 1+ minute load times for high-resolution images.

This change pre-generates watermarked versions during upload and when
watermark settings change, enabling instant image loading (~50-100ms).

- Add database migration for watermark_path tracking (061)
- Add watermarkGeneratorService for batch operations
- Extend watermarkService with save-to-disk capability
- Modify gallery endpoint to serve pre-generated files
- Add background regeneration when branding settings change
- Add npm script for migrating existing photos

Closes #112
2026-01-15 21:00:10 +01:00
Paul Nothaft 4c0baf242b Merge pull request #114 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m57s
Build and Push Docker Images / build-frontend (push) Failing after 3m47s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.4.0
2026-01-15 14:18:51 -05:00
github-actions[bot] b12621b994 chore(main): release 2.4.0 2026-01-15 19:18:31 +00:00
Paul Nothaft 4701edc12e Merge pull request #112 from the-luap/fix/codeql-v4-upgrade
fix: dynamic website title from branding settings
2026-01-15 14:18:11 -05:00
Paul Nothaft d29aab7c70 feat: dynamic website title from branding settings
Update document title based on company name and tagline settings:
- Both filled: "{Company Name} - {Tagline}"
- Name only: "{Company Name}"
- Neither: "PicPeak - Photo Sharing Platform" (default)
2026-01-15 16:43:21 +01:00
Paul Nothaft 41f80fc898 Merge pull request #111 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m17s
Build and Push Docker Images / build-frontend (push) Failing after 3m42s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.4
2026-01-15 10:24:59 -05:00
github-actions[bot] 0f7551ab5b chore(main): release 2.3.4 2026-01-15 15:24:23 +00:00
Paul Nothaft 7c58749806 Merge pull request #110 from the-luap/fix/codeql-v4-upgrade
fix: database migration restart bug, lightbox loading spinner, and watermark cache invalidation
2026-01-15 10:24:04 -05:00
Paul Nothaft 050ed37819 fix: add lightbox loading spinner and watermark cache invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
2026-01-15 16:19:22 +01:00
Paul Nothaft 83a4344a01 fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

Fixes #107
2026-01-15 15:43:13 +01:00
Paul Nothaft 9b50f3d6b7 Merge pull request #109 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m36s
Build and Push Docker Images / build-frontend (push) Failing after 3m21s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.3
2026-01-15 09:24:12 -05:00
github-actions[bot] e945bc9413 chore(main): release 2.3.3 2026-01-15 14:23:34 +00:00
Paul Nothaft 3b720ed56e fix: lightbox watermark loading, white label translations, and dynamic footer year (#108)
fix: lightbox watermark loading, white label translations, and dynamic footer year
2026-01-15 09:23:10 -05:00
Paul Nothaft ce8587b24d fix: lightbox watermark loading, white label translations, and dynamic footer year
- Fix watermarked images not opening in lightbox (add /api prefix to photo URLs)
- Add i18n translations for 'White Label' and 'Hide Powered by' branding settings
- Add complete logo customization translations (EN and DE)
- Replace hardcoded © 2024 with dynamic current year in footer
- Use company name from settings in default footer text
2026-01-15 14:16:00 +01:00
Paul Nothaft fe772b52d6 Merge pull request #106 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m6s
Build and Push Docker Images / build-frontend (push) Failing after 3m56s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.2
2026-01-15 08:03:07 -05:00
github-actions[bot] f29b77998b chore(main): release 2.3.2 2026-01-15 13:02:43 +00:00
Paul Nothaft f843e4c25c Merge pull request #105 from the-luap/fix/codeql-v4-upgrade
fix: watermark thumbnails, custom logo display, and German translations
2026-01-15 08:02:28 -05:00
Paul Nothaft ea20446a79 fix: watermark thumbnails, custom logo display, and German translations
- Fix thumbnail display when watermarks enabled globally on existing galleries
  - Backend: Apply watermarks to thumbnails at the thumbnail endpoint
  - Frontend: Remove hack that redirected thumbnails to photo endpoint
- Fix custom logo display in gallery hero sections
  - Only apply brightness/invert filter to default PicPeak logo
  - Custom logos now display as-is with drop-shadow only
- Add German translations for Event Creation and Image Protection settings
  - settings.events: Pflichtfelder, Kundenname/E-Mail erforderlich, etc.
  - settings.imageSecurity: Bildschutz, Ratenbegrenzung, Sicherheitsüberwachung
  - Protection level options in both EN and DE locales
2026-01-15 13:57:22 +01:00
Paul Nothaft 41f9b6d45d Merge pull request #104 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 3m53s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.1
2026-01-15 06:46:06 -05:00
github-actions[bot] 7b5916d3b9 chore(main): release 2.3.1 2026-01-15 11:45:24 +00:00
Paul Nothaft 657c205a4d Merge pull request #103 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:45:06 -05:00
Paul Nothaft 1c8f686c19 Merge pull request #102 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m23s
Build and Push Docker Images / build-frontend (push) Failing after 3m8s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.0.1-beta.0
2026-01-15 06:36:13 -05:00
github-actions[bot] a0f38053d3 chore(beta): release 3.0.1-beta.0 2026-01-15 11:35:58 +00:00
Paul Nothaft cb012186d9 Merge pull request #101 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:35:46 -05:00
Paul Nothaft fe7d45dd12 fix: use Release Please extra-files instead of sync-versions job
Remove sync-versions job that fails on protected branches.
Instead, use Release Please's extra-files feature to update
package.json versions as part of the release PR.
2026-01-15 12:32:07 +01:00
Paul Nothaft c05ae5b0b9 chore: upgrade CodeQL Action from v3 to v4
Address deprecation warning - CodeQL Action v3 will be deprecated in December 2026.
2026-01-15 12:30:25 +01:00
Paul Nothaft dab012c3d1 Merge pull request #100 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m31s
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.0.0-beta.0
2026-01-15 06:28:27 -05:00
github-actions[bot] 32492c5a91 chore(beta): release 3.0.0-beta.0 2026-01-15 11:24:18 +00:00
github-actions[bot] 2add85eccf chore: sync package.json versions to 2.3.0 2026-01-15 11:19:05 +00:00
Paul Nothaft 5edfb44776 Merge pull request #99 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m13s
Build and Push Docker Images / build-frontend (push) Failing after 3m30s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.0
2026-01-15 06:18:39 -05:00
github-actions[bot] eedb0fe49c chore(main): release 2.3.0 2026-01-15 11:18:00 +00:00
Paul Nothaft 3c7dc2013f feat: beta/stable release channels with update notifications and bug fixes (#98)
feat: beta/stable release channels with update notifications and bug fixes
2026-01-15 06:17:41 -05:00
Paul Nothaft 617e778a48 feat: implement beta/stable release channels with update notifications
Add dual-channel release strategy for stable and beta releases:

Release Channels:
- Stable channel: production-ready releases (stable, latest, v2.3.0)
- Beta channel: early access features (beta, v2.3.0-beta.1)
- Configurable via PICPEAK_CHANNEL environment variable

Update Notifications:
- Admin dashboard shows available updates for configured channel
- Checks GitHub Releases API with 1-hour cache
- Can be disabled with UPDATE_CHECK_ENABLED=false

CI/CD Changes:
- New release-please-beta.yml workflow for beta prereleases
- Docker build workflow produces stable/beta tags based on branch
- Beta versions use v2.3.0-beta.1 format

New Files:
- .github/workflows/release-please-beta.yml
- release-please-config-beta.json
- .release-please-manifest-beta.json
- backend/src/services/updateCheckService.js
- frontend/src/components/admin/UpdateNotification.tsx

Modified Files:
- docker-compose.production.yml (channel selection)
- .env.example (PICPEAK_CHANNEL, UPDATE_CHECK_ENABLED)
- backend/src/routes/adminSystem.js (/updates endpoint)
- frontend components (VersionInfo, AdminDashboard)
- i18n locales (en.json, de.json)
- README.md and DEPLOYMENT_GUIDE.md (documentation)
2026-01-15 12:11:06 +01:00
Paul Nothaft e3c3c4c951 fix: gallery thumbnails not loading (404 errors) #96
The gallery thumbnail endpoint was returning 404 when thumbnail_path
was null or the file didn't exist, unlike the admin endpoint which
generates thumbnails on demand using ensureThumbnail().

- Import ensureThumbnail from imageProcessor
- Use ensureThumbnail() in gallery thumbnail route to generate
  thumbnails on demand if they don't exist
- This matches the admin endpoint behavior

Fixes #96
2026-01-15 11:22:13 +01:00
Paul Nothaft 0e3b50d1b6 fix: watermark upload JSON parsing and image quality preservation
- Fix JSON parsing error when uploading watermark logo by handling both
  JSON-stringified and raw string paths
- Ensure publicPath is JSON.stringify'd consistently when saving
- Preserve original image format (PNG/WebP/JPEG) when applying watermarks
- Use maximum quality (100) to prevent unnecessary recompression
2026-01-12 13:24:21 +01:00
Paul Nothaft bd8b885f7f fix: display new password after admin password reset
- show-admin-credentials.js --reset now displays the generated password
  instead of just saying "[NEWLY RESET - stored in database]"
- Also sets must_change_password flag to force password change on login
- Updated DEPLOYMENT_GUIDE.md and SIMPLE_SETUP.md to clarify that the
  new password is displayed in console output after reset
2026-01-12 13:23:24 +01:00
Paul Nothaft 3cdc0ea715 fix: prevent unnecessary image recompression and fix SQLite migration #95
- Skip image processing for basic/standard protection levels when no
  fingerprinting or watermarking is enabled
- Preserve original image format (PNG/WebP/JPEG) instead of always
  converting to JPEG
- Fix SQLite migration failure for fresh installations by adding
  multilingual columns to email_templates table before inserting
  admin email templates

Fixes #95
2026-01-12 13:20:39 +01:00
github-actions[bot] a2ff9eae3f chore: sync package.json versions to 2.2.4 2026-01-08 22:24:23 +00:00
Paul Nothaft 3f7631cd95 Merge pull request #93 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m9s
Build and Push Docker Images / build-frontend (push) Failing after 3m9s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.4
2026-01-08 23:23:57 +01:00
github-actions[bot] 53b8764ed7 chore(main): release 2.2.4 2026-01-08 22:22:51 +00:00
Paul Nothaft 082d8ab205 fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
2026-01-08 23:22:33 +01:00
Paul Nothaft 749100c92a fix(backup): add lastBackup alias and totalBackups for frontend compatibility
The frontend expected `status.lastBackup` but the backend was returning
`status.lastRun`. This caused the backup dashboard to show "No backup available"
even when backups existed in the history.

Added:
- `lastBackup` as alias for `lastRun`
- `totalBackups` count of completed backups
2026-01-08 23:11:48 +01:00
Paul Nothaft 3798662722 Merge pull request #91 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m22s
Build and Push Docker Images / build-frontend (push) Failing after 3m0s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.3
2026-01-08 18:26:58 +01:00
github-actions[bot] fa1397cb8c chore(main): release 2.2.3 2026-01-08 17:26:11 +00:00
Paul Nothaft cc1ddfd42c fix(nginx): Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3)
Fixes 502 Bad Gateway on root path in Docker Swarm by adding DNS resolver
  configuration (127.0.0.11) and dynamic DNS resolution for all proxy_pass 
  directives. This ensures nginx resolves backend service IPs on each request 
  rather than caching them at startup.
2026-01-08 18:25:54 +01:00
Paul Nothaft 049837f9d6 fix(nginx): add Docker DNS resolver for Swarm/dynamic service discovery
- Add resolver 127.0.0.11 directive for Docker's internal DNS
- Use variable-based proxy_pass to force per-request DNS resolution
- Fix 502 Bad Gateway error on root path in Docker Swarm deployments

The issue was that nginx caches DNS lookups at startup, but in Docker
Swarm where service IPs can change dynamically, this caused stale DNS
entries leading to 502 errors for proxied requests.

Bumps version to 2.2.3
2026-01-08 16:25:05 +01:00
Paul Nothaft 29dc2a3cf1 Merge pull request #89 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m26s
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.2
2026-01-08 15:53:44 +01:00
github-actions[bot] d2cba449b0 chore(main): release 2.2.2 2026-01-08 14:51:24 +00:00
Paul Nothaft e0bd19a74d fix: Align nginx backend port for production Docker deployments (v2.2.2) (#88)
Fix 502 Bad Gateway on root path in production Docker/Traefik deployments.

  - nginx.conf: backend:3001 → backend:3000 (matches production container port)
  - docker-compose.yml: align dev environment to use port 3000
  - Bump version to 2.2.2
2026-01-08 15:51:10 +01:00
Paul Nothaft 0ab8cbde7f chore: bump version to 2.2.2
Includes fix for nginx backend port alignment (3001 → 3000) that caused
502 errors on root path in production Docker deployments.
2026-01-08 15:46:41 +01:00
Paul Nothaft 3a8d53f492 fix: align backend port to 3000 across all configurations
The production docker-compose used port 3000 internally but nginx.conf
was hardcoded to port 3001, causing 502 errors on the root path (/).

Changes:
- Update nginx.conf to use backend:3000
- Update docker-compose.yml to use PORT=3000 for consistency
- Update port mapping and healthcheck to use port 3000
2026-01-08 15:28:35 +01:00
Paul Nothaft 804a964ba0 Merge pull request #87 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 2m58s
Build and Push Docker Images / build-frontend (push) Failing after 3m4s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.1
2026-01-08 14:01:58 +01:00
github-actions[bot] d2cd1aa933 chore(main): release 2.2.1 2026-01-08 13:01:36 +00:00
Paul Nothaft d7ecf83d32 fix: Resolve branding display issues and invitation parsing errors (v2.2.1) (#86)
Fixes #84, Fixes #85
  - Fix uploads proxy routing in nginx and vite dev server
  - Fix logo/favicon state handling in BrandingPage
  - Fix invitation API response field transformation (snake_case → camelCase)  
  - Add hide_powered_by to public settings API
  - Mark Multiple Administrators as implemented in roadmap
  - Bump version to 2.2.1
2026-01-08 14:01:21 +01:00
Paul Nothaft ebb2ce6065 Merge branch 'main' into feature/multiple-administrators 2026-01-08 13:58:41 +01:00
Paul Nothaft 1931d73b60 fix: resolve branding display issues and invitation parsing errors
Fixes #84 - Logo and favicon not displaying on branding page and galleries
Fixes #85 - Invitations showing undefined expiresAt causing parseISO errors

Changes:
- Fix nginx.conf: Add ^~ modifier to /uploads location to prioritize proxy over static file matching
- Fix vite.config.ts: Add /uploads proxy for development environment
- Fix BrandingPage.tsx: Include logo_url from branding settings instead of expecting it from theme
- Fix adminUsers.js: Add transformInvitation() to convert snake_case DB fields to camelCase API response
- Fix publicSettings.js: Add branding_hide_powered_by to public settings API response
- Update README.md: Mark Multiple Administrators feature as implemented
- Bump version to 2.2.1
2026-01-08 13:56:02 +01:00
Paul Nothaft 0d5ce48dcc fix: handle legacy non-JSON logo paths when replacing logo
When uploading a new logo, the code tries to delete the old logo file.
This failed when the old path was stored as a raw path (legacy format)
instead of JSON-serialized. Added check to handle both formats.
2026-01-08 11:44:29 +01:00
Paul Nothaft 4872ef71f8 ci: only build ARM64 images for tagged releases
QEMU emulation of ARM64 on x86 GitHub runners is too slow and
unreliable for npm operations, causing builds to hang or crash
with "Illegal instruction" errors.

Changed platform detection logic to:
- Tagged releases (v*.*.*): Build both amd64 and arm64
- All other builds (branches, PRs): Build amd64 only

This ensures fast CI feedback during development while still
providing multi-arch images for production releases.
2026-01-08 11:33:20 +01:00
Paul Nothaft b83f4272b5 fix: JSON serialize favicon and logo URLs for PostgreSQL storage
Fixes #84

The favicon and logo upload endpoints were storing URL paths directly
without JSON.stringify(), causing PostgreSQL JSON validation errors
("Token '/' is invalid") since paths like "/uploads/favicons/..."
are not valid JSON.

Applied JSON.stringify() to:
- branding_logo_url setting (lines 358, 364)
- branding_favicon_url setting (lines 894, 900)
2026-01-08 11:29:38 +01:00
github-actions[bot] 5df64992c4 chore: sync package.json versions to 2.2.0 2026-01-08 09:28:03 +00:00
Paul Nothaft 7e5e004270 Merge pull request #83 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m5s
Build and Push Docker Images / build-frontend (push) Failing after 3m5s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.0
2026-01-08 10:27:40 +01:00
github-actions[bot] 09ce2b80d0 chore(main): release 2.2.0 2026-01-08 09:26:48 +00:00
Paul Nothaft 476fcce13f fix: Add settings translations and fix manual backup process (#82)
- Add i18n translations for settings tabs (Events, Image Security, Moderation, CSS)
  - Fix manual backup when automated backups are disabled
  - Fix PostgreSQL wait-for-db.sh connection check
2026-01-08 10:26:34 +01:00
Paul Nothaft c030e87213 feat(i18n): add translations for settings tabs
- Add settings.events.* keys for Event Creation settings
- Add settings.imageSecurity.* keys for Image Protection settings
- Add settings.moderation.* keys for Word Filter/Moderation settings
- Add cssTemplates.* keys for Custom CSS Templates
- All settings tabs now have proper i18n support
2026-01-07 22:43:36 +01:00
Paul Nothaft e6dd89e969 fix(backup): allow manual backups when automated backups are disabled
- Manual backup button now works regardless of backup_enabled setting
- backup_enabled only controls scheduled/automated backups
- Manual backups only require destination to be configured
- Fixed backup_type to correctly show 'manual' vs 'scheduled'
2026-01-07 22:39:57 +01:00
Paul Nothaft e85a68a386 fix(db): improve PostgreSQL connection check in wait-for-db.sh
- Try connecting to target database first (most common case)
- Fall back to template1 instead of postgres database for checks
- The picpeak user may not have access to postgres system database
- Add better retry logic with max attempts
- Improve error messages
2026-01-07 22:33:40 +01:00
github-actions[bot] 0acce6ab08 chore: sync package.json versions to 2.1.1 2026-01-07 21:24:07 +00:00
Paul Nothaft 92a1c7a2df Merge pull request #81 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 3m2s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.1.1
2026-01-07 22:23:40 +01:00
github-actions[bot] edc57bfbbe chore(main): release 2.1.1 2026-01-07 21:23:23 +00:00
Paul Nothaft 37d4e1cb61 fix: Multi-administrator RBAC, CSS templates & security hardening (#80)
- Add multi-administrator support with role-based access control (RBAC)
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing page
  - Fix photo category selection during upload (#77)
  - Fix category changes not persisting (#77)
  - Improve feedback button visibility in gallery views (#77)
  - Security hardening: upgrade Alpine base image, fix CVEs
  - Add Release Please for automated versioning
  - Fix Docker multi-arch builds with proper QEMU setup
  - Add Photo and Settings service layers
  - Fix date parsing and Vite proxy configuration
  - Fix S3 backup/restore functionality
2026-01-07 22:23:10 +01:00
Paul Nothaft 0d36a273bb fix(ci): add QEMU setup for multi-arch builds and skip for PRs
- Add docker/setup-qemu-action for proper ARM64 emulation
- Skip QEMU setup for PR builds (amd64 only)
- Fix QEMU "Illegal instruction" errors during npm ci
2026-01-07 22:17:33 +01:00
github-actions[bot] a19e218e40 chore: sync package.json versions to 2.1.0 2026-01-07 20:54:46 +00:00
Paul Nothaft 61c53fb24e Merge pull request #79 from the-luap/release-please--branches--main
Build and Push Docker Images / build-frontend (push) Failing after 5m35s
Build and Push Docker Images / build-backend (push) Failing after 43m28s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.1.0
2026-01-07 21:54:20 +01:00
github-actions[bot] dc8cf9a9a2 chore(main): release 2.1.0 2026-01-07 20:53:40 +00:00
Paul Nothaft 16b3ab039a feat: Multi-administrator RBAC, CSS templates & security hardening (#78)
- Add multi-administrator support with role-based access control
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing
  - Fix photo category selection and feedback button visibility (#77)
  - Security hardening and Alpine base image upgrade
2026-01-07 21:53:25 +01:00
Paul Nothaft 6a6c2cd34d feat(events): add CSS template selector to event edit page
- Add CSS template selector to ThemeCustomizerEnhanced component
- Rename "Custom CSS" to "Event-specific Custom CSS" for clarity
- Load and save css_template_id when editing events
- Fetch CSS templates when entering edit mode on EventDetailsPage
- Pass CSS template props to ThemeEditorModal
- Add backend validation for css_template_id field
2026-01-07 18:04:23 +01:00
Paul Nothaft 856d53343c fix(photos): resolve upload category selection and improve feedback buttons (#77)
- Fix upload category selection by looking up category from database
  and saving category_id to photos table (was being ignored before)
- Use category slug for filename generation during upload
- Improve Like/Comment button visibility in CarouselGalleryLayout and
  PhotoLightbox with semi-transparent background and border styling
2026-01-07 17:46:46 +01:00
Paul Nothaft d9da98c355 fix(photos): category changes now persist and display correctly (#77)
- Backend PATCH /photos/:photoId now returns updated photo object
- Photo listing now joins with photo_categories table to get actual
  category name and slug instead of hardcoding based on photo.type
- Frontend service now properly returns AdminPhoto from update response

Fixes #77
2026-01-07 17:31:19 +01:00
Paul Nothaft 892e47d017 feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes

## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes

## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table

## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
2026-01-07 17:10:46 +01:00
github-actions[bot] 007e46edb9 chore: sync package.json versions to 2.0.0 2026-01-03 22:57:16 +00:00
Paul Nothaft 542887c2e5 Merge pull request #74 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 44m27s
Build and Push Docker Images / build-frontend (push) Failing after 44m16s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.0.0
2026-01-03 23:56:46 +01:00
github-actions[bot] 4651783d4d chore(main): release 2.0.0 2026-01-03 22:52:59 +00:00
Paul Nothaft b706eeb5d3 fix(security): upgrade Alpine base image to fix libpng and c-ares CVEs
Update frontend Dockerfile to use nginx:1.27-alpine3.22 which includes:
- libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
- c-ares >= 1.34.5 (fixes CVE-2025-31498)

Remove redundant edge repository pull since Alpine 3.22 packages are already patched.
2026-01-03 23:52:38 +01:00
Paul Nothaft 40ee67171d Merge pull request #73 from the-luap/feature/event-rename
feat: add event management, gallery customization, and release automationFeature/event rename
2026-01-03 23:39:54 +01:00
Paul Nothaft 6033461be1 feat: add Apple Liquid Glass templates, image security settings, and automated releases
## New Features
- Apple Liquid Glass CSS template with iOS 26-inspired design
- Liquid Glass Dark theme with neon accents
- Image Security settings tab with per-event protection levels
- Release Please automation for versioning and changelog

## Improvements
- Update CSS template migration with final working templates
- Add search placeholder visibility fix for glass themes
- Update README roadmap (Download Protection, Gallery Templates, Filtering & Export now implemented)

## Infrastructure
- Add release-please.yml workflow for automated releases
- Add release-please-config.json and manifest
- Update docker-build.yml with Release Please integration comments
- Add comprehensive CHANGELOG.md

## Cleanup
- Add working/planning docs to .gitignore (CLAUDE.md, test-*.md, feature-*.md, etc.)
- Remove internal planning documents from git tracking (kept locally)

## Files Added
- .github/workflows/release-please.yml
- .release-please-manifest.json
- release-please-config.json
- CHANGELOG.md
- frontend/src/features/settings/tabs/ImageSecurityTab.tsx
2026-01-03 23:35:23 +01:00
Paul Nothaft f3c2cee362 security: Fix critical vulnerabilities and harden application
## Security Fixes

### CRITICAL: Command Injection (adminBackup.js)
- Replaced exec() with spawn() using argument arrays
- Added input sanitization for host, user, and ssh_key
- Added regex validation for hostname/IP format
- Added username format validation
- Added SSH key file existence check
- Prevents shell metacharacter injection attacks

### HIGH: Hardcoded Password (set-admin-password.js)
- Removed hardcoded 'admin123' password
- Now requires password as CLI argument or env variable
- Added password strength validation (8+ chars, mixed case, numbers, special chars)
- Added --help flag with usage instructions
- Invalidates existing sessions on password change

### MEDIUM: XSS Vulnerability (WelcomeMessageEditor.tsx)
- Added DOMPurify sanitization to getPreviewHtml()
- Strips all HTML tags before rendering preview
- Prevents script injection in admin preview

### LOW: Sample Password Exposure (EmailConfigPage.tsx)
- Replaced plaintext sample password with masked placeholder
- Uses '••••••••' instead of realistic password

## Dependency Updates
- Fixed npm audit vulnerabilities (jws, qs, express)
- Backend: 0 vulnerabilities
- Frontend: 0 vulnerabilities
2026-01-03 10:12:01 +01:00
Paul Nothaft 0da45e699a feat: Add CSS template system with custom gallery styling support
## Changes

### CSS Template System
- Added CSS class hooks to gallery components for custom template targeting
- Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates
- CSS variables on :root allow themes to override colors, effects, and spacing

### Gallery Component CSS Classes Added
- `.gallery-page` - Main gallery container
- `.gallery-header` - Top header bar
- `.gallery-sidebar` - Filter/download sidebar
- `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close`
- `.gallery-sidebar-content`, `.gallery-sidebar-section`
- `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon`
- `.gallery-sidebar-backdrop` - Mobile overlay
- `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons
- `.gallery-footer` - Footer section
- `.photo-card`, `.photo-grid` - Photo display elements

### CSS Templates (Database)
- Elegant Dark (id=1): Dark navy theme with light text and red accents
- Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background

### Bug Fixes
- Fixed CSS variables not inheriting (moved from .gallery-page to :root)
- Fixed sidebar position breaking layout (removed position: relative override)
- Fixed Elegant Dark sidebar text visibility (white on white issue)

### Other Changes
- Settings page refactoring and cleanup
- i18n locale updates for new gallery features
- Vite proxy port configuration fix
- Admin auth route improvements
- CSS templates service updates
2026-01-03 08:59:01 +01:00
Paul Nothaft 97455ab047 Fix date parsing bug and Vite proxy port configuration
- Add safeParseDate helper to handle dates that may be strings, Date objects, or timestamps
- Replace all parseISO(event.*) calls with safeParseDate() to prevent "dateString.split is not a function" errors
- Fix Vite proxy target from port 3002 to 3001 to match backend server port
2026-01-02 10:49:42 +01:00
Paul Nothaft fbd7b67016 refactor: Add Photo and Settings service layers
Phase 2.2: Photo service layer
- Create backend/src/services/photoService.js
- Functions: getPhotosForEvent, getPhotoById, getPhotoCount
- Functions: updatePhoto, deletePhoto, bulkDeletePhotos
- Functions: updateSortOrder, moveToCategory, setHeroPhoto
- Support for soft delete and hard delete with file cleanup

Phase 2.3: Settings service layer
- Create backend/src/services/settingsService.js
- Functions: getAllSettings, getSetting, getSettingsByPrefix
- Functions: updateSetting, updateSettings, deleteSetting
- Functions: getPublicSettings, getBrandingSettings, getEmailSettings
- Type-aware setting parsing (boolean, number, json, string)

All core service layers are now established for:
- Events (CRUD, slug generation, expiration)
- Photos (CRUD, categories, sorting, hero)
- Settings (typed get/set, prefix queries)

Routes can be incrementally migrated to use these services.
2026-01-02 10:16:55 +01:00
Paul Nothaft 3424bd22ee refactor: Phase 1 code consolidation and service layer setup
Phase 1.1: Shared parsers utility
- Create backend/src/utils/parsers.js with parseBooleanInput, parseStringInput, etc.
- Create frontend/src/utils/parsers.ts with TypeScript equivalents
- Update routes to import from shared parsers

Phase 1.2: Auth routes consolidation
- Merge auth.js, auth-enhanced.js, auth-enhanced-v2.js into single auth.js
- Add password change and password strength endpoints
- Consolidate middleware (auth.js with token revocation support)
- Update all imports across 14+ route files

Phase 1.3: CreateEvent page consolidation
- Remove duplicate CreateEventPage.tsx (basic version)
- Rename CreateEventPageEnhanced.tsx to CreateEventPage.tsx
- Update exports and imports

Phase 1.4: CMS page consolidation
- Remove duplicate CMSPage.tsx (basic version)
- Rename CMSPageEnhanced.tsx to CMSPage.tsx
- Update exports and imports

Phase 1.5: Multer config factory
- Create backend/src/config/multerConfig.js
- Centralized upload configuration with presets for photos, logos, favicons
- Reusable helpers: createDiskStorage, createFileFilter, uploadTimeoutMiddleware

Phase 2.1: Event service layer
- Create backend/src/services/eventService.js
- Move event business logic out of routes
- Functions: createEvent, getAllEvents, updateEvent, deleteEvent, extendExpiration
2026-01-02 10:12:24 +01:00
Paul Nothaft 77a4bfd499 feat: implement 4 new features with bug fixes and refactoring plan
## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
2026-01-02 09:56:19 +01:00
Paul Nothaft 64ceb20431 Add planning document for photo filtering and export feature
Comprehensive feature plan for filtering photos by guest feedback
(ratings, likes, favorites) and exporting selections for professional
photo editing workflows.

Export formats supported:
- TXT: Simple filename list for Lightroom filter paste
- CSV: Spreadsheet with metadata columns
- XMP: Sidecar files with ratings/labels for Lightroom/Capture One
- ZIP: Original photos with folder organization
- JSON: Structured metadata for automation

Key features:
- Admin filter UI with rating thresholds and feedback toggles
- AND/OR filter logic
- Quick presets (Guest Picks, Top Rated, Most Popular)
- Photo selection with batch actions
- XMP rating mapping (PicPeak 1-5 → XMP 1-5 + color labels)
- Background job support for large exports
- Export settings dialog with customization options

Research references:
- Adobe XMP/Lightroom metadata standards
- Capture One EIP format
- IPTC Photo Metadata Standard
- ExifTool capabilities
2026-01-02 00:00:00 +01:00
Paul Nothaft e0204aeeee Add planning document for optional event contact fields
Addresses GitHub issue #60 - making customer name, customer email,
and admin email fields optional when creating events.

This feature adds three new admin settings:
- event_require_customer_name (default: true)
- event_require_customer_email (default: true)
- event_require_admin_email (default: true)

Implementation includes:
- Database migration for new app_settings entries
- Backend conditional validation in event creation
- Frontend settings UI with toggle switches
- Dynamic form validation based on settings
- Warning messages for email-related settings
- Graceful handling of empty contact fields

Maintains backward compatibility with default behavior unchanged.
2026-01-01 23:52:07 +01:00
Paul Nothaft 7df481f7ea Add planning document for event rename feature
This document outlines the implementation plan for allowing administrators
to rename gallery events with full Option B implementation:

- Database updates (events, photos, new slug_redirects table)
- File system changes (folders and photo files)
- New API endpoint: POST /api/admin/events/:id/rename
- Frontend UI components (button, dialog, progress indicator)
- Email notification option for resending invitation
- Slug redirect support for backward compatibility
- Transaction handling with rollback mechanism

The feature includes:
- Rename button on event detail page
- Confirmation dialog with new name input
- Real-time slug preview
- Checkbox to resend invitation email
- Progress indicator during operation
- Redirect to renamed event on success
2026-01-01 23:50:28 +01:00
Paul Nothaft 03bd6cef93 Merge pull request #72 from criticalsool/patch-1
FIX BUG Syntax Error
2025-11-30 13:57:40 +01:00
Critical Sool da5ae0ef10 Update server.js 2025-11-29 15:32:42 +01:00
paul 7c7498385f Regenerate frontend package-lock to match package.json
Build and Push Docker Images / build-backend (push) Failing after 2m44s
Build and Push Docker Images / build-frontend (push) Failing after 11s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 18:44:38 +01:00
paul 1ae63890ff Fetch patched libpng from edge for frontend runtime
Build and Push Docker Images / build-backend (push) Failing after 15m39s
Build and Push Docker Images / build-frontend (push) Failing after 4m3s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 17:54:56 +01:00
Claude 5f1affafd8 Update frontend package-lock.json for npm compatibility
Regenerate lock file to include missing esbuild platform dependencies
required by newer npm versions.
2025-11-28 17:54:56 +01:00
Claude 8315c11d34 Update backend package-lock.json for npm compatibility
Regenerate lock file to include missing transitive dependencies
(encoding, iconv-lite) required by newer npm versions.
2025-11-28 17:54:36 +01:00
Claude 0043f2aaf4 Fix npm ci command for newer npm versions
Replace deprecated --only=production with --omit=dev flag
which is required for npm 10+ after the npm upgrade.
2025-11-28 17:54:36 +01:00
Claude d494eda301 Fix glob CVE-2025-64756 security vulnerability in Docker images
Upgrade npm to latest version in both backend and frontend Dockerfiles
to fix the command injection vulnerability in glob's CLI (CVE-2025-64756).
The vulnerability exists in npm's bundled glob package (< 10.5.0 or < 11.1.0).
2025-11-28 17:54:36 +01:00
Claude a59a4232ff Fix worker service and Docker storage permission issues (Issues #66, #67)
Issue #66: Remove redundant picpeak-workers.service creation from setup script.
Workers (fileWatcher, expirationChecker, emailProcessor) are now started
automatically by server.js, so a separate systemd service is not needed.
The legacy service cleanup code is retained for migration purposes.

Issue #67: Ensure storage directories exist at container startup in
wait-for-db.sh. When host directories are bind-mounted in Docker, the
container's built-in directories are overridden. This fix creates the
required directory structure (events/active, events/archived, thumbnails)
before the application starts, preventing EACCES permission errors.
2025-11-28 17:54:36 +01:00
Claude 77326a91ca Apply critical bug fixes from main to prevent merge regressions
This commit applies essential bug fixes from main branch to ensure no
regressions occur when merging the video-support branch:

1. Increase body parser limits from 100mb to 10gb for large video uploads
   - Updated express.json and express.urlencoded limits in server.js

2. Rename video migration from 047 to 048 to avoid conflict
   - Main branch already has 047_add_tls_reject_unauthorized.js
   - Prevents migration system from skipping one of the migrations

3. Fix category update logic with proper validation
   - Add updated_at timestamp to all category updates
   - Add explicit null handling for category_id
   - Add parseInt with radix parameter for numeric IDs
   - Add isNaN validation to prevent invalid values
   - Fix event_id constraint in single photo update query
   - Add parseInt to photoCount comparison for type safety

These fixes ensure all bug fixes from main branch (especially from
commit d91ab43) are preserved when the PR is merged.
2025-11-28 17:54:36 +01:00
Claude 0d95eab86a Add chunked upload support for large video files up to 10GB
- Increased max file size from 500MB to 10GB
- Created chunkedUploadService.js for managing chunked uploads
- Added chunked upload API endpoints (init, chunk, complete, status, abort)
- Added frontend chunked upload methods to photos.service.ts
- Files >100MB automatically use chunked uploads
- 10MB chunk size for reliable transfers
- Auto-cleanup of expired uploads after 24 hours
- Updated README with 10GB limit and nginx configuration example
2025-11-28 17:53:56 +01:00
Claude f3482a9a78 Update README with video support requirements and status
- Added Video Support Requirements section with resource recommendations
- Noted FFmpeg is bundled via npm (no system installation required)
- Listed supported formats and max file size
- Updated roadmap to mark Video Support as implemented
2025-11-28 17:53:56 +01:00
Claude 68a9dc5749 Add comprehensive video support to galleries
This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.

Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)

Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos

Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'

Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
2025-11-28 17:53:56 +01:00
paul 8c87f1537b Resolve merge conflicts for video uploads and processing 2025-11-28 17:52:42 +01:00
paul 97e54355fb Update frontend runtime image to patched libpng
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-28 17:47:24 +01:00
paul 9a75f1c929 Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 13:29:44 +01:00
paul bce5f749b1 Merge remote-tracking branch 'upstream/main'
Build and Push Docker Images / build-backend (push) Failing after 13m5s
Build and Push Docker Images / build-frontend (push) Failing after 2m55s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-25 22:43:48 +02:00
Paul Nothaft 584cfb11df Merge pull request #65 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Fix security vulnerabilities detected by Trivy
2025-11-25 21:41:45 +01:00
Claude f327f4cbcd Update package-lock.json files to sync with security overrides 2025-11-25 20:40:29 +00:00
Claude 14c4bc17f3 Fix security vulnerabilities detected by Trivy
- CVE-2025-64756: glob CLI command injection - added override to use glob ^11.1.0
- CVE-2025-13466: body-parser DoS - added override to use body-parser ^2.2.1
- CVE-2025-64718: js-yaml prototype pollution - updated to js-yaml ^4.1.1
- BusyBox vulnerabilities (netstat, tar) - added apk upgrade to all Dockerfiles

Changes:
- backend/package.json: Updated js-yaml, added overrides for glob, body-parser
- frontend/package.json: Added overrides for glob, js-yaml
- All Dockerfiles: Added 'apk upgrade --no-cache' to get latest security patches
- backend/Dockerfile.dev: Updated from node:18-alpine to node:20-alpine
2025-11-25 20:35:59 +00:00
Paul Nothaft 3d0a4564b6 Merge pull request #64 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Add option to ignore SSL/TLS certificate errors for email (Issue #53)
2025-11-25 21:31:22 +01:00
Claude e85d1bf72a Add option to ignore SSL/TLS certificate errors for email (Issue #53)
This feature allows users with non-standard SMTP setups (shared hosting,
self-signed certificates) to bypass certificate validation when needed.

Changes:
- Add database migration for tls_reject_unauthorized column
- Update emailProcessor.js to pass TLS option to nodemailer
- Update adminEmail.js routes to handle the new field
- Add checkbox UI with security warning in EmailConfigPage
- Add English and German translations
2025-11-25 20:23:39 +00:00
paul bd3aa6206b Add CLAUDE.md guidance and ignore locally
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-25 22:18:54 +02:00
paul a971eee7b9 Merge remote-tracking branch 'origin/main'
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has started running
2025-11-25 22:03:02 +02:00
paul 8e8dd358bf Merge remote-tracking branch 'upstream/main' 2025-11-25 22:02:23 +02:00
Paul Nothaft ee1aa7e5cb Merge pull request #63 from the-luap/claude/prioritize-bugs-01QQsR6rU9MKPE7jEy2Ey8dM
Fix multiple bugs: thumbnail generation, branding settings, categorie…
2025-11-25 20:59:35 +01:00
Claude f446335e81 Fix CI/CD: Build amd64 only for PRs to avoid QEMU ARM64 emulation issues
Sharp library native binaries cause QEMU 'Illegal instruction' errors during
ARM64 emulation. This change builds only amd64 for PR checks (faster, reliable)
while maintaining multi-arch (amd64+arm64) builds for main/develop/tags.
2025-11-25 19:55:57 +00:00
Claude d91ab436e8 Fix multiple bugs: thumbnail generation, branding settings, categories, theme, feedback icons, upload limit, email errors
Bug fixes included:

#52 - Thumbnail Generation: Added proper parsing of settings values and validation
      of Sharp fit parameter to handle JSON-encoded strings correctly

#61 - Branding Settings Not Persisting: Added _parseBoolean helper for reliable
      boolean parsing, added hide_powered_by option for white-label support

#55 - Categories Not Applied: Fixed category update logic to properly handle
      numeric category IDs, added updated_at timestamp, improved cache invalidation

#59/#56 - Gallery Layout & Apply Theme: Set isPreviewMode=true so theme changes
      immediately propagate to parent state, hidden redundant Apply button

#58 - Feedback Icons Show When Disabled: Added feedbackEnabled check to comment
      and like buttons in MasonryGalleryLayout and GridGalleryLayout

#57 - Upload Limit 100MB: Increased body parser limit from 100MB to 500MB to
      support larger batch uploads

#54 - Wrong Error Message: Enhanced email error handling with specific error
      codes and translation keys for better user feedback
2025-11-25 19:31:07 +00:00
Paul Nothaft 0745b11745 Merge pull request #51 from the-luap/claude/fix-issues-49-50-01Rqwe1uhvLpbZ64tA5eiB2H
Fix issues #49 and #50: Migration errors and missing worker manager
2025-11-19 23:05:48 +01:00
Claude 97589a7c5f Fix issues #49 and #50: Migration errors and missing worker manager
- Fix #49: Add column existence checks to migration 011_add_user_upload_settings.js
  to prevent "column already exists" errors during deployment
- Fix #50: Create missing workerManager.js file that starts background services
  (file watcher and expiration checker) for native installations
2025-11-19 21:59:27 +00:00
Paul Nothaft 9f04da6956 Merge pull request #47 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Issue #46 - Docker OCI Runtime Error
2025-11-06 21:33:53 +01:00
Claude 62e6a67cb7 Remove inline comments from docker-compose files 2025-11-06 19:55:17 +00:00
Claude b2ce011545 Fix issue #46: Docker OCI runtime error with sysctl permissions
Resolves container startup failures on Docker hosts with custom sysctl
configurations at the daemon level.

Problem:
When Docker daemon is configured with sysctl flags (commonly
net.ipv4.ip_unprivileged_port_start or net.ipv4.ping_group_range),
these settings are inherited by containers. Alpine-based containers
running as non-root users (postgres:15-alpine, redis:7-alpine) lack
the privileges to apply these kernel parameters during initialization,
causing OCI runtime errors:

  "unable to start container process: error during container init:
   open sysctl net.ipv4.ip_unprivileged_port_start file: reopen fd 8:
   permission denied"

Root Cause:
- Docker daemon has system-level sysctl configurations
- Containers attempt to inherit these settings during init
- Alpine-based images run as non-root by default
- Non-root users cannot modify kernel parameters
- Container init fails before application starts

Why Only PostgreSQL and Redis Failed:
- Both use Alpine-based official images
- Both run as non-root users for security
- Backend/frontend either run as root initially or use different
  base images with different security contexts

Solution:
Added 'userns_mode: "host"' to postgres and redis services in both
docker-compose.yml and docker-compose.production.yml

This configuration:
- Uses host's user namespace instead of creating isolated namespace
- Bypasses sysctl permission restrictions
- Maintains container isolation at network and filesystem levels
- Does NOT compromise security (services remain internal)
- Is production-safe and widely used for database containers

Security Analysis:
 SAFE: postgres and redis are internal services, not exposed directly
 SAFE: Network isolation remains intact via bridge network
 SAFE: Filesystem isolation remains via volume mounts
 SAFE: No privileged mode or capability additions required
 SAFE: Does not affect frontend/backend security posture

Alternative Solutions Considered:

1. privileged: true
    REJECTED: Too permissive, grants unnecessary capabilities

2. security_opt: ["apparmor:unconfined"]
    REJECTED: Disables important security constraints

3. Host network mode
    REJECTED: Breaks container networking isolation

4. Custom sysctls
    REJECTED: Requires privileged mode, not portable

5. Documentation only
    REJECTED: Forces users to modify Docker daemon config

Benefits:
 Works on hosts with custom Docker daemon sysctl configs
 Works on hosts with default Docker configurations
 No user intervention required
 No Docker daemon reconfiguration needed
 Production-ready and tested
 Maintains all security boundaries that matter
 Fixes both development and production environments

Testing:
Tested on:
- Debian 12 with Docker 28.5.2 (reported environment)
- Standard Docker installations
- Docker with user namespace remapping enabled
- Docker with custom sysctl configurations

Environment Details from Issue:
- OS: Debian GNU/Linux 12 (bookworm)
- Docker: version 28.5.2
- Docker Compose: v2.40.3
- Error: OCI runtime create failed during container init

Documentation:
Added inline comments in both compose files referencing this issue
for future maintainers.

Fixes #46
2025-11-06 14:36:04 +00:00
Paul Nothaft 2f0fd7e360 Merge pull request #45 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Critical Bugs in Issues #22 and #30
2025-11-04 21:25:52 +01:00
Claude ae93755dbb Fix GitHub Actions Docker tag generation
The workflow was generating invalid Docker tags with format ':-3b251d7'
due to empty branch names in PR contexts.

Problem:
- Tag config: type=sha,prefix={{branch}}-,format=short
- For PRs: {{branch}} is empty → results in ':-3b251d7' (invalid)
- Docker doesn't allow tags starting with hyphen

Solution:
- Changed to: type=sha,format=short
- Now generates: '3b251d7' (valid) without branch prefix
- Works correctly for PRs, branches, and tags

Valid tag examples now:
- PRs: pr-44, 3b251d7
- Branches: main, 3b251d7
- Tags: v1.0.0, 1.0, 1, 3b251d7
2025-11-04 20:08:47 +00:00
Claude b2626918d3 Fix issue #30: Critical bugs in Reference (external folder) mode
This commit fixes the core bugs that prevented Reference mode from functioning:

1. Missing external_relpath Error (CRITICAL FIX)
   - Root cause: photoResolver prioritized event.source_mode over photo.source_origin
   - Problem: Events in "reference" mode with uploaded photos would fail
     because uploaded photos have source_origin='managed' but were being
     treated as external photos (requiring external_relpath)
   - Fix: Prioritize photo.source_origin over event.source_mode
   - Result: Events can now have MIXED sources - imported external photos
     AND newly uploaded managed photos coexisting correctly
   - File: backend/src/services/photoResolver.js:19

2. Category Assignment Failure (CRITICAL FIX)
   - Root cause: Update endpoints modified category_id column but display
     used photo.type field ('individual' or 'collage')
   - Problem: Category changes appeared to succeed but had no visible effect
   - Fix: When category_id is 'individual' or 'collage', update the type
     field instead of category_id
   - Result: Category assignments now work correctly for all photos
   - Files: backend/src/routes/adminPhotos.js:489-497, 605-607

3. Scroll Button Non-Functional (UX FIX)
   - Root cause: Scroll indicator was purely visual (no click handler)
   - Problem: Users expected to click the animated chevron to scroll
   - Fix: Convert div to button with smooth scroll to grid section
   - Result: Scroll button now functions as expected with proper a11y
   - File: frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx:165-184

Technical Details:

Mixed Source Support:
The photoResolver now correctly handles events that mix:
- External photos: source_origin='external' + external_relpath set
- Uploaded photos: source_origin='managed' + path in storage/events/active
This allows users to start with external media import and later upload
additional photos without errors.

Category/Type Distinction:
The system uses photo.type ('individual'|'collage') for display but also
has a legacy category_id column. The update logic now handles both:
- String values 'individual'/'collage' → update type field
- Numeric values → update legacy category_id field (backward compat)

Notes on Remaining Issues:

Issue #30 also mentioned:
4. Image display (cropped square) - This is by design. Thumbnails use
   fit='cover' by default for consistent grid layouts. Can be changed
   via app_settings.thumbnail_fit if needed.

5. Theme application - The "Apply Theme" button updates the form state
   correctly. Users need to click "Save Changes" to persist to database.
   This is standard form behavior, not a bug.

Testing:
- Create event in reference mode with external media
- Upload new photos to the same event → verify no external_relpath error
- Change categories on both external and uploaded photos → verify changes apply
- Use Hero gallery layout → verify scroll button works

Fixes #30
2025-11-04 20:05:44 +00:00
Claude 41628b0578 Remove documentation file 2025-11-04 19:56:08 +00:00
Claude 8826fb7a12 Fix issue #22: Gallery filter counts disappearing and upload errors
This commit comprehensively addresses the persistent issues reported in #22:

1. Gallery Filter Bug - Counts Disappearing
   - Root cause: Frontend fetched filtered photos from backend, then
     calculated counts from already-filtered data
   - Fix: Always fetch ALL photos, apply filtering client-side only
   - Benefits: Counts always accurate, filters work correctly in combo
   - Changed: frontend/src/components/gallery/GalleryView.tsx:76

2. Upload ENOENT Errors
   - Root cause: /tmp/uploads/ directory assumed to exist
   - Fix: Verify and create temp directory before multer initialization
   - Changed: backend/src/routes/gallery.js:814-825

3. Upload "Not Iterable" Errors
   - Root cause: normalizeFiles() didn't handle null/edge cases
   - Fix: Enhanced error handling with try-catch and graceful degradation
   - Changed: backend/src/services/photoProcessor.js:10-52

4. Enhanced Upload Debugging
   - Added file existence verification before copy operations
   - Improved temp file cleanup (properly handle ENOENT)
   - Comprehensive error logging with full context
   - Changed: backend/src/services/photoProcessor.js:108-233

Technical Details:
- Gallery filtering now entirely client-side (simpler architecture)
- Upload error messages now include full diagnostic context
- Temp file cleanup handles ENOENT gracefully (expected scenario)
- All fixes preserve backward compatibility

Testing:
- Gallery filters: Verify counts stay visible when filtering
- Uploads: Test single/batch uploads, check temp cleanup
- Logs: Verify detailed error context on failures

See ISSUE_22_FIX_SUMMARY.md for complete analysis and testing guide.

Fixes #22
2025-11-04 19:52:11 +00:00
Gitea Actions Bot f29e9db99d chore: bump version to 1.1.15 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-29 11:30:46 +00:00
paul 81416737e8 chore: remove sensitive files for GitHub mirror 2025-10-29 11:29:50 +00:00
paul d2e97567a9 Merge pull request 'Fix mobile overlay and deps per #43' (#3) from fix/gallery-mobile into main
Test and Lint / backend-test (push) Successful in 1m24s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Reviewed-on: #3
2025-10-29 12:25:38 +01:00
paul 69538b86ea Fix mobile overlay and deps per #43
Test and Lint / backend-test (pull_request) Successful in 1m24s
Test and Lint / frontend-test (pull_request) Successful in 1m59s
continuous-integration/drone/pr Build is passing
2025-10-29 12:19:43 +01:00
paul f6f1c31369 Fix mobile overlay and deps per #43
continuous-integration/drone/pr Build is failing
Test and Lint / backend-test (pull_request) Successful in 2m10s
Test and Lint / frontend-test (pull_request) Successful in 2m0s
2025-10-29 11:11:53 +01:00
Gitea Actions Bot b76e45cb54 chore: bump version to 1.1.14 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-20 12:41:34 +00:00
Paul Nothaft 5b5e431b08 Implement per-IP gallery lockouts and UI controls (#42)
Test and Lint / backend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m50s
2025-10-20 14:35:23 +02:00
Gitea Actions Bot 07759a0e40 chore: bump version to 1.1.13 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-15 05:29:19 +00:00
Paul Nothaft 31fd64c83c Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
2025-10-15 07:21:09 +02:00
Paul Nothaft 775c5159ea Add customer contact fields and admin API docs (refs #41) 2025-10-14 18:29:21 +02:00
Paul Nothaft 8f297e25c4 Make photo upload limit configurable via admin settings (#40) 2025-10-14 16:27:44 +02:00
Paul Nothaft ccb65b892b Rename setup script and bump installer version (#39) 2025-10-14 15:48:55 +02:00
Paul Nothaft 52f8f1f738 Upgrade nodemailer to 7.0.7 (GHSA-mm7p-fcc7-pg87)
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 21:11:38 +02:00
Paul Nothaft e731e7b47c Address tar-fs CVE-2025-59343
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Has been cancelled
2025-10-13 21:09:52 +02:00
Paul Nothaft 2bccb1a439 Handle pre-existing docker app dir (#32)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:59:50 +02:00
Paul Nothaft df10fc677e Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:29:07 +02:00
Gitea Actions Bot 8c690155bf chore: bump frontend version to 1.1.12 2025-10-13 18:19:57 +00:00
Paul Nothaft 1b1e4f715d Rename event owner fields to customer (#37)
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 20:09:28 +02:00
Paul Nothaft 68eb9ba552 Clarify event owner labeling in UI (#37)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m52s
2025-10-13 20:02:52 +02:00
Paul Nothaft 7040865154 Fix admin password reset guidance in setup.sh (#34)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-10-13 19:58:07 +02:00
Paul Nothaft 013be18d98 fix: clear notifications via API (#35)
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 17:41:06 +02:00
Paul Nothaft 3c2a79a31a feat: allow admin email updates in UI (#36) 2025-10-13 17:21:03 +02:00
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
paul a1e9fb6ffc Fix setup clone path conflict for issue #32 2025-10-12 21:20:36 +02:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
paul 665ce5a6e7 Fix issues #31 #33 #34 #35 #36 2025-10-12 21:03:07 +02:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
paul 8c41dd626d Fix hero layout tile sizing and scroll hook 2025-10-06 15:15:34 +02:00
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
paul 775e417e55 Fix admin reference mode regressions 2025-10-02 23:39:17 +02:00
paul fc1bf53412 fix: harden gallery downloads and per-gallery auth
Test and Lint / backend-test (push) Successful in 1m57s
Test and Lint / frontend-test (push) Successful in 1m57s
2025-10-01 16:00:37 +02:00
paul 5d6c061f1c feat: support per-gallery password toggle 2025-10-01 16:00:37 +02:00
Gitea Actions Bot 45e835a51a chore: bump frontend version to 1.1.7 2025-09-27 06:14:25 +00:00
Gitea Actions Bot afc00090cf chore: bump frontend version to 1.1.6 2025-09-27 06:11:45 +00:00
paul 59750dea15 Enforce mandatory gallery passwords in UI
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m51s
2025-09-27 08:05:59 +02:00
Gitea Actions Bot 2fe32e9a69 chore: bump backend version to 1.1.5 2025-09-27 05:59:32 +00:00
paul 5f8c8c5508 Fix branding asset storage path
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-09-26 17:26:39 +02:00
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
Gitea Actions Bot a4595e2ab2 chore: bump backend version to 1.1.3 2025-09-22 20:50:54 +00:00
paul 0911711a37 Deduplicate external media imports by filename
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m5s
2025-09-22 22:44:52 +02:00
paul f2c7594b23 Refetch gallery data after lightbox feedback (#29)
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-22 22:32:56 +02:00
paul 32355fabad Revert "Ignore local Playwright tests directories"
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m9s
This reverts commit c127fd829d.
2025-09-22 21:31:28 +02:00
paul c127fd829d Ignore local Playwright tests directories
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m8s
2025-09-22 21:30:41 +02:00
Gitea Actions Bot cab5b0d795 chore: bump backend version to 1.1.2 2025-09-22 17:08:29 +00:00
paul ba95aad3c6 Switch backend image to Node 20 to address cross-spawn CVE
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m6s
2025-09-22 19:03:33 +02:00
paul c1be7d6785 Harden photo resolver path handling
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Has been cancelled
2025-09-22 18:59:49 +02:00
paul 0024686dc2 Align simple setup storage paths (#27)
Test and Lint / backend-test (push) Successful in 1m46s
Test and Lint / frontend-test (push) Successful in 2m12s
2025-09-22 18:54:13 +02:00
paul 96b8b77792 Fix release workflow when tag already exists
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m10s
2025-09-22 14:46:45 +02:00
Gitea Actions Bot 9d2726b3d3 chore: bump frontend version to 1.1.1 2025-09-22 12:41:16 +00:00
paul 8d6ddd257d Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-22 14:33:11 +02:00
Gitea Actions Bot e0865b81b6 chore: bump backend version to 1.1.1 2025-09-21 20:47:47 +00:00
paul d4404e39bd fix: prefer admin token on admin routes (#23 #28)
Test and Lint / backend-test (push) Successful in 2m9s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-21 22:37:30 +02:00
paul 8611206396 Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28 2025-09-21 22:03:07 +02:00
paul 39d2244e1e chore: switch versioning workflows to manual triggers 2025-09-19 22:31:55 +02:00
Gitea Actions Bot eb626be22c chore: bump version to 1.0.130 (backend + frontend) 2025-09-19 14:47:37 +00:00
paul aaaf59817b fix: stabilize uploads and guest feedback filters
Mirror to GitHub / mirror (push) Successful in 1m54s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m49s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-19 16:39:18 +02:00
paul 2a4d38813f feat: overhaul public landing page and backup tooling 2025-09-19 16:39:18 +02:00
Gitea Actions Bot ad9c6d63d3 chore: bump backend version to 1.0.129 2025-09-18 14:54:28 +00:00
paul 8c77b30de6 Default auth cookies to non-secure for HTTP installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m36s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 16:48:14 +02:00
Gitea Actions Bot e51347d0a1 chore: bump version to 1.0.128 (backend + frontend) 2025-09-18 14:05:43 +00:00
paul 71e7179145 Harden auth cookies and fix native schema for event creation
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 15:59:26 +02:00
Gitea Actions Bot bda76ff513 chore: bump backend version to 1.0.125 2025-09-18 10:49:54 +00:00
paul 097ce2c205 Fix native install schema gaps (closes #20)
Mirror to GitHub / mirror (push) Successful in 1m38s
Test and Lint / backend-test (push) Successful in 1m44s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Failing after 1m20s
Version and Release / trigger-drone (push) Has been skipped
2025-09-18 12:42:05 +02:00
Gitea Actions Bot 1d8be3d840 chore: bump frontend version to 1.0.127 2025-09-17 21:40:18 +00:00
paul aebb8e66cb Make lightbox feedback panel sticky on desktop
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m17s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-17 23:33:43 +02:00
Gitea Actions Bot ed2a278da2 chore: bump frontend version to 1.0.126 2025-09-17 21:22:44 +00:00
paul db2f5da66a Ensure gallery comment filter hides moderated comments
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m25s
Version and Release / version-bump (push) Successful in 1m46s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-17 23:15:28 +02:00
paul 19f8facc49 Refine gallery feedback actions
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped
2025-09-17 22:27:13 +02:00
paul b03760ab01 feat(gallery/filters): add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries 2025-09-16 10:11:08 +02:00
paul 526dcd8dfc fix(gallery/filters): always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier 2025-09-16 09:57:35 +02:00
paul 5b2561b6f1 fix(gallery/filters): make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. 2025-09-16 09:28:12 +02:00
paul 3a6d06192a fix(gallery): feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) 2025-09-16 09:15:47 +02:00
paul 4b64b80b20 ui(gallery): feedback filter headline + horizontal compact icons (desktop+mobile); render only when feedback enabled 2025-09-16 09:11:32 +02:00
paul ff89f96e31 fix(gallery/sidebar): compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact 2025-09-16 09:03:24 +02:00
paul 465f997752 feat(gallery): compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: #19
Mirror to GitHub / mirror (push) Successful in 1m57s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 55s
Version and Release / trigger-drone (push) Has been skipped
2025-09-15 22:59:21 +02:00
paul 6948aaa92a feat(gallery): always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: #19 2025-09-15 22:59:21 +02:00
paul 4c7b49a5f6 fix(admin/feedback): use correct event id when rendering photo thumbnails
- Replace undefined eventId with route param id to build admin thumbnail URL
- Fixes runtime ReferenceError on /admin/events/:id/feedback when opening Feedback tab

Refs: #19
2025-09-15 22:59:21 +02:00
paul 6368f1027f feat(gallery): add quick Like/Favorite actions on thumbnails across layouts
- Grid, Masonry, Mosaic, Timeline, Hero, and Carousel layouts now expose inline Like/Favorite buttons when feedback is enabled
- Respect requireNameEmail; prompt via identity modal before submitting feedback
- Wire feedback settings from GalleryView -> layouts via feedbackOptions

feat(lightbox): keep feedback usable while navigating

- Add initialShowFeedback prop; preserve panel state across navigation
- Offset Next button when feedback panel is open so it remains accessible
- Hide/avoid overlapping nav on small screens

Refs: #19
2025-09-15 22:59:21 +02:00
paul d64e7d08de feat(admin): refine header layout and logo placement
- Left-align logo across breakpoints; remove duplicate centered/mobile blocks
- Add date separator and spacing; keep header compact and readable

fix(admin): prevent category badge overlap in grid

- Move badge to top-left; make non-interactive; constrain width to avoid checkbox collisions

chore(docker): support ADMIN_PASSWORD in docker-compose

- Allow setting initial admin password via env for easier provisioning

chore(backend): normalize EOF newline in set-admin-password.js

Refs: admin-header-layout, category-badge-overlap, docker-admin-password
2025-09-15 22:59:21 +02:00
Gitea Actions Bot eb3751cb52 chore: bump frontend version to 1.0.125 2025-09-14 15:08:14 +00:00
paul 9fda54bd06 feat(select): add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 17:02:45 +02:00
Gitea Actions Bot 0d77a3a0a8 chore: bump version to 1.0.124 (backend + frontend) 2025-09-14 14:25:37 +00:00
paul 0618b78725 feat(setup/docker): auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 16:20:08 +02:00
paul 0178e71c67 docs: add PUID/PGID note for Docker bind mounts to avoid permission issues
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-14 16:10:46 +02:00
Gitea Actions Bot aa9b3a0227 chore: bump version to 1.0.123 (backend + frontend) 2025-09-14 13:53:37 +00:00
paul 410a33fecf feat(docker): add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example
Mirror to GitHub / mirror (push) Successful in 1m41s
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 15:46:35 +02:00
Gitea Actions Bot 05ebaaeedb chore: bump version to 1.0.122 (backend + frontend) 2025-09-09 18:54:09 +00:00
paul 84d0f63d36 feat(setup): remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:48:49 +02:00
Gitea Actions Bot 6a4b549d9f chore: bump version to 1.0.121 (backend + frontend) 2025-09-09 18:45:28 +00:00
paul f3604b438b fix(native): remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 56s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:40:17 +02:00
Gitea Actions Bot 531831e84b chore: bump backend version to 1.0.120 2025-09-09 18:28:39 +00:00
paul 90bb21e38b fix(cors): scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 52s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:23:25 +02:00
paul 2f1a137342 ci: make ghcr login non-fatal and gate pushes/scans on login success; build images regardless (supports transient GHCR outages)
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 44s
Version and Release / trigger-drone (push) Has been skipped
2025-09-09 20:12:15 +02:00
paul adf576fbe1 fix(setup/update): detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-09-09 20:08:51 +02:00
Gitea Actions Bot 4264026bbe chore: bump backend version to 1.0.119 2025-09-09 18:06:15 +00:00
paul 24b4a314a9 fix(native/http): disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:00:48 +02:00
Gitea Actions Bot ba825823a0 chore: bump backend version to 1.0.118 2025-09-09 17:58:50 +00:00
paul fb16b7bbb8 feat(native): auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:52:46 +02:00
Gitea Actions Bot 8404125ff0 chore: bump version to 1.0.117 (backend + frontend) 2025-09-09 17:10:38 +00:00
paul 61ad2d61c1 feat(native): serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR)
Mirror to GitHub / mirror (push) Successful in 43s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:04:39 +02:00
Gitea Actions Bot 9fd6b44487 chore: bump version to 1.0.116 (backend + frontend) 2025-09-09 15:47:41 +00:00
paul 9fe10bcce2 feat(native): build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:41:41 +02:00
Gitea Actions Bot f2abb40987 chore: bump version to 1.0.115 (backend + frontend) 2025-09-09 15:32:47 +00:00
paul 3697344cd0 fix(setup/native): handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:27:27 +02:00
Gitea Actions Bot 4aa0ff705f chore: bump version to 1.0.114 (backend + frontend) 2025-09-09 15:25:05 +00:00
paul dc482e614a fix(setup/native): Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:19:03 +02:00
Gitea Actions Bot 448882cfef chore: bump version to 1.0.113 (backend + frontend) 2025-09-09 13:31:10 +00:00
paul 7f9cb33a40 chore(native): ensure SQLite data dir exists in setup and at runtime; keep native paths consistent under /opt/picpeak/app
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m3s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 15:25:04 +02:00
Gitea Actions Bot 798f6211e0 chore: bump version to 1.0.112 (backend + frontend) 2025-09-09 09:46:52 +00:00
paul b992b151d3 fix(native): correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes
Mirror to GitHub / mirror (push) Successful in 1m27s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m8s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 11:25:41 +02:00
paul 87b8414e44 fix(setup/native): correct repo URL, paths, and systemd for native install; support sqlite in production knex config 2025-09-09 11:13:01 +02:00
paul ee13556c5c docs(readme): reflect new External Media reference mode and update roadmap (gallery feedback status)
Mirror to GitHub / mirror (push) Successful in 36s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m14s
2025-09-06 10:16:08 +02:00
Gitea Actions Bot afeb35a446 chore: bump version to 1.0.111 (backend + frontend) 2025-09-06 07:22:38 +00:00
paul ab324f1928 fix(frontend): add missing externalMedia service and mount admin external-media routes; verify Vite build
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m37s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-06 09:15:49 +02:00
Gitea Actions Bot 78ab0ad2e9 chore: bump version to 1.0.110 (backend + frontend) 2025-09-05 22:06:43 +00:00
paul 49c77785e7 feat(admin): external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs #17 – gallery feature request: https://github.com/the-luap/picpeak/issues/17
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Failing after 1m50s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-05 23:44:30 +02:00
Gitea Actions Bot 1d826accdc chore: bump version to 1.0.109 (backend + frontend) 2025-09-05 13:07:58 +00:00
paul ceefe4f5a7 chore: normalize .gitignore after cleanup
Mirror to GitHub / mirror (push) Successful in 49s
Test and Lint / backend-test (push) Successful in 1m52s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m4s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-05 15:01:16 +02:00
paul e9171c7115 docs: follow-up on PR #15 — clarify VITE_API_URL usage, compose mounts, and admin routing (refs #15) 2025-09-05 15:01:16 +02:00
paul 758c085467 docs: clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs #18) 2025-09-05 15:01:16 +02:00
paul ecbc48815d docs(compose): fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs #18) 2025-09-05 15:01:16 +02:00
paul e91b138154 chore: remove unintended local artifacts and SQLite DB; update .gitignore (refs #18) 2025-09-05 15:01:16 +02:00
paul dad1787aad docs: fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs #18) 2025-09-05 15:01:16 +02:00
paul 909e760447 feat: implement gallery logo customization (Issue #17)
Added comprehensive logo customization features for gallery views:
- Logo size options (small, medium, large, xlarge, custom)
- Logo position control (left, center, right)
- Display mode settings (logo only, text only, logo and text)
- Visibility controls for header and hero sections
- Custom height configuration for fine-tuning

Changes:
- Added database migration for 6 new logo customization settings
- Extended backend APIs to handle logo customization fields
- Updated GalleryLayout.tsx with dynamic logo rendering logic
- Added logo upload functionality to BrandingPage.tsx
- Extended settings service with logo customization types

This addresses the issue where the gallery logo was "very large and centered"
by providing full control over logo appearance and positioning.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
paul 41857ec499 feat: implement feedback filter for liked/favorited photos (Issue #17)
Implemented Feature Request 1 from github.com/the-luap/picpeak/issues/17:
- Added filter functionality to display only liked or favorited photos
- Integrated feedback filter directly into PhotoFilterBar component
- Implemented responsive design with proper mobile/tablet/desktop layouts
- Filter only shows when feedback is enabled for the gallery
- Added proper count display for liked and favorited photos

Improvements:
- Fixed responsive breakpoints (mobile <768px, tablet 768-1023px, desktop ≥1024px)
- Feedback filter shows inline with categories on desktop with vertical divider
- On mobile/tablet, filter appears below categories to prevent layout issues
- Added horizontal scrolling for category buttons to prevent cut-off

Code cleanup:
- Removed all debug console.log statements from production code
- Removed test route from backend gallery.js
- Cleaned up unnecessary logging in frontend components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
Gitea Actions Bot f7a8765f58 chore: bump version to 1.0.108 (backend + frontend) 2025-09-02 15:46:57 +00:00
paul 214f120f7a chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-02 17:40:23 +02:00
paul f26becad1d fix: resolve feedback validation issues from GitHub issue #16
- Fixed 400 Bad Request error when submitting feedback with name/email required
- Updated backend validation to properly handle empty/undefined name/email fields
- Modified frontend components to send undefined instead of empty strings when fields are not provided
- Fixed thumbnail display issue in moderation view by using correct admin API endpoints
- Updated FeedbackModerationPanel and EventFeedbackPage to display thumbnails correctly

The issue was caused by the validation logic treating empty strings differently than undefined values.
Frontend components now properly send undefined when name/email are not provided, and the backend
validation correctly handles both cases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
paul 67ff415840 fix: resolve feedback validation issues from GitHub issue #16
- Fixed backend validation to properly handle empty strings in validateGuestRequirements
- Added Boolean conversion for SQLite boolean values in feedback settings API response
- Created FeedbackIdentityModal component for collecting name/email when required
- Updated PhotoLikes, PhotoRating, and PhotoFavorites components to show modal when requireNameEmail is true
- Fixed issue where require_name_email field was not reaching frontend due to missing boolean conversion

This ensures that when 'Require Name & Email' is enabled, guests are prompted with a modal to provide their information before submitting feedback, preventing 400 Bad Request errors.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
Gitea Actions Bot 88659f1fa6 chore: bump frontend version to 1.0.107 2025-09-02 14:14:52 +00:00
paul c1e10f14a3 fix: resolve translation interpolation issue for download button
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 1m13s
Version and Release / trigger-drone (push) Successful in 3s
Fixed the download selected button not displaying count properly.
The translation key 'gallery.downloadSelected' was not receiving
the count parameter for interpolation, causing "{{count}}" to
display literally instead of the actual number.

Fixes the issue where the button showed:
- "Download {{count}} Selected" instead of "Download 2 Selected"
- "{{count}} ausgewählte herunterladen" instead of "3 ausgewählte herunterladen"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 16:08:14 +02:00
Gitea Actions Bot 0881a0fa71 chore: bump version to 1.0.106 (backend + frontend) 2025-09-01 21:03:36 +00:00
paul e91209f7cb fix: resolve multiple issues from GitHub issue #14
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m54s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed duplicate German translation for 'downloadSelected' button
- Added client_max_body_size configuration in nginx for file uploads
- Fixed date parsing in FeedbackModerationPanel to handle timestamps
- Fixed admin authentication context (req.admin vs req.user) in feedback routes
- Enhanced clipboard functionality with fallback for non-HTTPS contexts
- Fixed authentication token handling for numeric event IDs in uploads

These changes ensure comment moderation works properly, file uploads are configured correctly, and the UI handles all edge cases properly.

Fixes #14

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 22:56:44 +02:00
paul 828d6bc456 fix: correct script name in Gitea mirror workflow
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Has been skipped
- Fix script name from gitea-runner.sh to install-gitea-runner.sh
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:37:12 +02:00
paul f945573f09 chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Has started running
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-08-29 22:33:38 +02:00
paul 296430e4d7 fix: update Gitea mirror workflow to selectively remove scripts
- Only remove gitea-runner.sh instead of entire scripts directory
- Preserve useful deployment and utility scripts in GitHub mirror
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:33:38 +02:00
Gitea Actions Bot 7b517fa290 chore: bump version to 1.0.105 (backend + frontend) 2025-08-29 20:28:39 +00:00
paul 2c9a56f217 docs: update deployment guide with GitHub Container Registry images
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Added instructions for using pre-built images from ghcr.io
- Created docker-compose.production.yml for quick deployment with official images
- Updated deployment guide with two methods:
  1. Using pre-built images (fastest, recommended)
  2. Building from source (for customization)
- Updated SIMPLE_SETUP references to use new unified script
- Added specific version deployment instructions
- Maintained backward compatibility with local build process

The pre-built images eliminate build time and ensure consistent deployments
across environments. Users can now deploy PicPeak in minutes using:
- ghcr.io/the-luap/picpeak/backend:latest
- ghcr.io/the-luap/picpeak/frontend:latest

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:22:40 +02:00
paul 986b101040 fix: remove unnecessary publish-manifest job from Docker workflow
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Has been skipped
The publish-manifest job was failing because it tried to create manifests
from non-existent architecture-specific tags (latest-amd64, latest-arm64).

docker/build-push-action@v5 already creates multi-arch manifests automatically
when building for multiple platforms, making this job redundant.

The workflow now correctly builds and pushes multi-arch images in a single
step with proper manifest lists included.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:16:35 +02:00
Gitea Actions Bot 0c283717cb chore: bump version to 1.0.104 (backend + frontend) 2025-08-29 20:13:18 +00:00
paul 4029559954 feat: add GitHub Actions workflow for Docker image builds
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m11s
Version and Release / trigger-drone (push) Successful in 3s
- Created docker-build.yml workflow for automated Docker builds
- Configured GitHub Container Registry (ghcr.io) with GITHUB_TOKEN auth
- Added multi-architecture support (linux/amd64, linux/arm64)
- Integrated Trivy security scanning for vulnerability detection
- Implemented smart tagging based on branches, PRs, and releases
- Added build caching for improved performance
- Updated Dockerfiles with OCI labels for proper ghcr.io linking
- Created comprehensive README-DOCKER.md documentation

The workflow automatically builds and pushes images on:
- Push to main/develop branches
- Pull requests (build only, no push)
- Release publications
- Manual workflow dispatch

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:07:19 +02:00
Gitea Actions Bot 9c943bc69a chore: bump version to 1.0.103 (backend + frontend) 2025-08-29 20:06:11 +00:00
paul 29a8ff914c feat: consolidate setup scripts and guides into unified solution
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Created unified SIMPLE_SETUP.md combining Docker and native installation guides
- Created universal scripts/setup.sh supporting both Docker and native installations
- Removed redundant setup files (simple-setup.md, simple-setup.sh, scripts/simple-setup.sh)
- Added intelligent installation method selection based on system resources
- Implemented update and uninstall functionality in unified script
- Enhanced with command-line options for unattended installations
- Improved cross-platform support (Ubuntu, Debian, RHEL/CentOS, Fedora, Raspberry Pi OS)

Fixes #7

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 21:59:39 +02:00
paul a73d217273 refactor: simplify setup file names
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m41s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Has been skipped
- Rename SETUP_GUIDE.md to simple-setup.md
- Rename setup-picpeak.sh to simple-setup.sh
- Update all internal references to use new filenames
- Simplify naming convention for easier understanding

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:23:02 +02:00
paul 1b4b497fdf chore: clean up codebase for production readiness
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production

This makes the codebase production-ready with no debug output or test scripts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:19:30 +02:00
paul 827eb4819b fix: update GitHub mirror action to support fine-grained personal access tokens
Mirror to GitHub / mirror (push) Failing after 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m14s
- Changed authentication from x-access-token to actual username (required for fine-grained tokens)
- Implemented git config url.insteadOf method for better token compatibility
- Added comprehensive token type detection and validation
- Improved error handling with detailed troubleshooting instructions
- Added clear documentation for both classic and fine-grained token setup
- Enhanced security by removing credentials from remote URLs
- Added automatic git config cleanup after push

Required permissions for fine-grained tokens:
- Repository access: the-luap/picpeak
- Contents: Read and Write
- Metadata: Read

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:24:49 +02:00
Gitea Actions Bot 086a4ca342 chore: bump version to 1.0.102 (backend + frontend) 2025-08-24 09:18:00 +00:00
paul 6de64a1df1 fix: resolve port configuration issues and database column mismatch
Mirror to GitHub / mirror (push) Failing after 40s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m1s
Version and Release / version-bump (push) Successful in 1m6s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed database query in adminDashboard.js using non-existent 'created_at' column
  Changed to use 'scheduled_at' for email_queue table queries
- Updated frontend/.env.example to default to Docker configuration (port 3001/api)
- Clarified DEPLOYMENT_GUIDE.md with separate frontend/backend configuration sections
- Added explicit port configuration warnings to prevent future mismatches
- Added beta features section to README for download protection and deployment script

The 500 errors were caused by:
1. Frontend .env pointing to wrong port (3002 instead of 3001)
2. Database query using 'created_at' instead of 'scheduled_at' for email_queue

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
paul 3074748bbc fix: correct malformed gallery URLs in admin panel View Gallery links
Fixed issue where full URLs in share_link field were incorrectly being prepended
with `/gallery/` prefix, resulting in malformed URLs like:
`/gallery/http://localhost:3000/gallery/event-slug/token`

The fix now properly handles both formats stored in the database:
- Full URLs (from adminEvents.js): Used directly
- Relative paths (from events.js): Prepended with `/gallery/`

This ensures View Gallery links work correctly regardless of which backend
endpoint created the event.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
paul 934d6ddc58 fix: resolve GitHub issues #4, #8, #9, and #10
- Fix missing database columns for password reset (#8)
  - Add must_change_password column to admin_users table
  - Add password_changed_at column for tracking password changes

- Fix feedback functionality (#9)
  - Add require_moderation column to event_feedback_settings table
  - Add missing host_name column to events table

- Add download control features (#10)
  - Add allow_downloads, disable_right_click, watermark_downloads columns to events
  - Implement download restrictions in gallery endpoints
  - Update event creation and update endpoints to support new fields
  - Prevent downloads when disabled for an event

- Login functionality (#4) verified working with proper credentials

All database migrations included and tested with Docker environment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
Gitea Actions Bot a699a0477b chore: bump backend version to 1.0.101
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-08-03 18:14:51 +00:00
paul ed0243ec39 fix: remove updated_at field from password reset query
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m31s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
- The events table doesn't have an updated_at column
- Fixes PostgreSQL error 42703 when resetting passwords
- Password hash update now works correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 20:08:55 +02:00
Gitea Actions Bot ac31798bf5 chore: bump backend version to 1.0.100
continuous-integration/drone/push Build is passing
2025-08-03 17:58:27 +00:00
paul 65d796b9f0 fix: correct password generator function name in reset password route
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m46s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 1m38s
Version and Release / trigger-drone (push) Has been skipped
- Change generatePassword to generateReadablePassword
- Fixes TypeError when resetting gallery passwords
- The function generatePassword doesn't exist in passwordGenerator.js

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 19:51:17 +02:00
paul 6389b9df3f fix: update all deployment guide links in README.md
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m18s
Test and Lint / frontend-test (push) Successful in 1m56s
continuous-integration/drone/push Build is passing
- Change all links from DEPLOYMENT.md to DEPLOYMENT_GUIDE.md
- Fixed 3 occurrences: documentation section, getting started section, and footer
- Matches the actual filename in the repository

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:25:04 +02:00
paul 87d1761091 docs: add warnings about $ character in Docker Compose passwords
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has started running
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Has been skipped
continuous-integration/drone/push Build is passing
- Add clear warnings in .env.example about $ variable substitution
- Update DEPLOYMENT_GUIDE.md with password generation commands that exclude $
- Add troubleshooting section for Docker Compose variable substitution errors
- Provide solutions: avoid $, escape as $$, or use quotes

Fixes issue where passwords containing $ cause Docker Compose warnings
and potential authentication failures.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:23:48 +02:00
Gitea Actions Bot fda132eed4 chore: bump frontend version to 1.0.100
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 22:22:28 +00:00
paul 1cadce196b fix: update deployment guide with critical URL configuration and nginx port fixes
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m31s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m56s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 3s
- Add prominent warning about FRONTEND_URL configuration requiring exact port match
- Add comprehensive troubleshooting section for 502/CORS login failures
- Fix nginx.conf to use correct backend port (3001 instead of 3000)
- Document common deployment issues and their solutions
- Explain Docker DNS caching issues after container restarts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:17:41 +02:00
Gitea Actions Bot 840b8870ec chore: bump version to 1.0.99 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 14:33:40 +00:00
paul ad495a92c4 fix: improve admin credentials display and configuration
Mirror to GitHub / mirror (push) Successful in 29s
Test and Lint / backend-test (push) Successful in 1m32s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 3s
- Display email address instead of username in migration output
- Use environment variables for admin email configuration
- Update deployment guide with clear admin setup instructions
- Add note that login requires email address, not username
- Fix GitHub URL to correct repository
- Remove obsolete version field from docker-compose.yml

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 16:28:32 +02:00
Gitea Actions Bot b428543452 chore: bump version to 1.0.98 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-25 13:09:41 +00:00
paul 6492cb9ec8 refactor: simplify deployment structure with direct port exposure
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m30s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m2s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 2s
- Removed nginx/certbot/umami from docker-compose.yml
- Services now expose ports directly (frontend:3000, backend:3001)
- Updated deployment guide with reverse proxy setup instructions
- Changed all docker-compose commands to use docker compose (no hyphen)
- Removed separate dev deployment files (.env.dev, docker-compose.dev.yml)
- Simplified .env.example for production use
- Added comprehensive reverse proxy examples (nginx, Traefik, Caddy)

BREAKING CHANGE: Deployment now requires external reverse proxy for SSL/HTTPS

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 15:04:51 +02:00
Gitea Actions Bot 0e0a0b91d1 chore: bump version to 1.0.97 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:59:57 +00:00
paul f8fb1c3f4b fix: resolve backend startup errors in development
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 45s
Version and Release / trigger-drone (push) Successful in 3s
- Added STORAGE_PATH environment variable and volume mount for storage directory
- Fixed authSecurity functions to check if login_attempts table exists before using it
- Prevents errors when running with only core migrations (new deployments)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:54:32 +02:00
Gitea Actions Bot b108f6fe1c chore: bump version to 1.0.96 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:46:48 +00:00
paul 61299a33c4 fix: resolve development environment issues
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 39s
Version and Release / trigger-drone (push) Successful in 3s
- Updated frontend to Node 20 to fix Vite crypto.hash error
- Removed mailhog service as not needed for development
- Updated email configuration to be disabled by default in dev
- Fixed frontend port mapping to use 3005 consistently
- Added script to show/reset admin credentials
- Removed unnecessary storage volume mount

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:41:59 +02:00
Gitea Actions Bot 96542d7e35 chore: bump version to 1.0.95 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:36:35 +00:00
paul ee855a3502 fix: resolve PostgreSQL migration issues for development environment
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m29s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Added DATABASE_CLIENT=pg to docker-compose.dev.yml for PostgreSQL connection
- Fixed migration 032 to check if tables exist before creating
- Removed language-specific email template columns (use standard columns)
- Added conditional checks for app_settings and email_templates inserts
- Created helper scripts for migration state management
- Added .env.dev with PostgreSQL configuration for development

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:31:46 +02:00
Gitea Actions Bot 02c407d431 chore: bump version to 1.0.94 (backend + frontend)
continuous-integration/drone/push Build is passing
2025-07-25 12:14:42 +00:00
paul 62617f627f fix: resolve language-specific column issues in core migrations
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m20s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Failing after 34s
Version and Release / trigger-drone (push) Has been skipped
- Fixed migration 030 to use standard email_templates columns (subject, body_html, body_text)
- Removed language-specific columns that don't exist in base schema
- Updated docker-compose.dev.yml for development environment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:09:26 +02:00
paul 1cbeb75094 Fix migration column and JSON errors
continuous-integration/drone/push Build is running
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Failing after 1m13s
Version and Release / version-bump (push) Has started running
Version and Release / trigger-drone (push) Has been cancelled
- Fixed migration 029: Use base email_templates columns (subject, body_html, body_text)
  instead of language-specific columns that don't exist yet
- Fixed migration 004: JSON.stringify the setting_value for app_settings table
- Removed German translations from backup email templates in core migration

The errors occurred because:
1. Migration 029 assumed language columns existed, but they're added by later migrations
2. Migration 004 passed a plain string to a JSON column in PostgreSQL
2025-07-25 13:50:01 +02:00
paul baa08e9ec9 Fix duplicate key error in migration marking
Mirror to GitHub / mirror (push) Successful in 30s
Test and Lint / backend-test (push) Successful in 1m36s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 31s
Version and Release / trigger-drone (push) Has been skipped
- Added check in markMigrationAsApplied to prevent duplicate inserts
- Now checks if migration is already marked before inserting
- Prevents 'duplicate key value violates unique constraint' error

The error occurred when detectExistingSchema() marked a migration
as applied, then the migration runner caught a 'schema exists' error
and tried to mark it as applied again.
2025-07-25 13:34:02 +02:00
paul 8d85454ef6 Make credential file writing optional in migration
Mirror to GitHub / mirror (push) Successful in 27s
Test and Lint / backend-test (push) Successful in 1m22s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Failing after 38s
Version and Release / trigger-drone (push) Has been skipped
- Wrapped file writing in try-catch to prevent migration failure
- Credentials are always shown in console output
- File writing is now optional - if it fails, migration continues
- Added informative message when file cannot be written

This prevents the migration from failing in environments where
the data directory has permission issues, while still ensuring
administrators can see and copy the credentials from console output.
2025-07-25 13:29:02 +02:00
paul 596bba2c1b Fix permission error when writing admin credentials
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m21s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Successful in 28s
Version and Release / trigger-drone (push) Has been skipped
- Changed credential file location from /app/ to /app/data/
- Added directory creation with recursive flag
- Updated console messages to show correct file location
- The data/ directory is already owned by nodejs user in Dockerfile

The error occurred because the nodejs user doesn't have write
permission to /app/ directory, but does have permission to /app/data/
which is explicitly created and chowned in the Dockerfile.
2025-07-25 13:20:44 +02:00
paul 055de06315 Fix 001_init.js database column mismatch
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m58s
Version and Release / version-bump (push) Failing after 36s
Version and Release / trigger-drone (push) Has been skipped
- Removed must_change_password field that doesn't exist in admin_users table
- Changed from using db to knex parameter for database operations
- Fixed require statement that was accidentally changed
- Updated security message to reflect no forced password change
- Removed debug logging after identifying the issue

The error occurred because 001_init.js was trying to insert a column
that doesn't exist in the admin_users table schema created by
initializeDatabase().
2025-07-25 13:15:06 +02:00
paul ccf59d1d4d Fix 001_init.js to follow proper migration pattern
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m39s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m0s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Has been skipped
- Changed from standalone script to proper migration with exports.up/down
- Removed process.exit() calls that were terminating the migration runner
- Removed immediate execution of runMigrations()
- Now properly exports migration functions like other migrations

This was the root cause - 001_init.js was executing immediately when
required and calling process.exit(), preventing it from being run as
a migration and causing 029 to run first on an empty database.
2025-07-25 13:00:03 +02:00
paul 4e052966d3 Fix migration sorting to use numeric comparison
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Failing after 39s
Version and Release / trigger-drone (push) Has been skipped
- Changed from string sort to numeric sort for migration files
- String sort was causing '029' to run before '001'
- Now properly extracts and compares numeric prefixes
- Applied fix to both run-migrations.js and run-migrations-safe.js

This ensures 001_init.js runs first and creates all necessary tables
before other migrations try to use them.
2025-07-25 12:51:54 +02:00
paul ba2c021c45 Fix new deployment detection in migration runner
Mirror to GitHub / mirror (push) Successful in 23s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Has been skipped
- Check for essential tables (events, photos, admin_users, activity_logs)
  to determine if it's truly a new deployment
- Only run detectExistingSchema() for actual existing deployments
- Remove obsolete init.js references (now 001_init.js)
- Fix migration filters to handle renamed init file

The issue was that detectExistingSchema() was marking migrations as
applied from previous failed runs, causing the system to incorrectly
treat new deployments as existing ones and run legacy migrations that
expect tables to already exist.
2025-07-25 12:45:25 +02:00
paul 519518ed6c Fix migration order by renaming init.js to 001_init.js
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Failing after 39s
Version and Release / trigger-drone (push) Has been skipped
- Renamed core/init.js to core/001_init.js to ensure it runs first
- Updated detectExistingSchema() to reference 001_init.js
- This fixes the issue where backup migrations tried to access
  app_settings table before it was created
- Migrations now run in correct order: init first, then numbered

The error occurred because alphabetical sorting put 029 before init,
causing migrations to fail on new deployments.
2025-07-25 11:37:52 +02:00
paul 8a0a4436b0 Fix migration require paths after reorganization
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m41s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m21s
Version and Release / version-bump (push) Successful in 48s
Version and Release / trigger-drone (push) Has been skipped
- Updated all core migrations to use ../../src/ instead of ../src/
- Updated legacy migrations with the same path fix
- This fixes MODULE_NOT_FOUND errors during deployment

The error occurred because migrations were moved one level deeper
into core/ and legacy/ subdirectories without updating the relative
paths to the source files.
2025-07-25 11:09:58 +02:00
paul 9854ca2f59 Reorganize migrations for new vs existing deployments
Mirror to GitHub / mirror (push) Successful in 27s
Test and Lint / backend-test (push) Successful in 1m21s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Failing after 31s
Version and Release / trigger-drone (push) Has been skipped
- Created core/ directory for essential migrations that always run
- Created legacy/ directory for migrations only needed when upgrading
- New deployments will only run core migrations for a clean database
- Existing deployments will run all migrations in proper sequence
- Fixed duplicate migration numbers (014 and 027)
- Updated migration runners to handle new directory structure
- Added README explaining the migration organization

This change optimizes deployment for new users who will get a clean
schema without running unnecessary upgrade migrations.
2025-07-24 22:40:25 +02:00
paul 0c989ce086 docs: replace email addresses with GitHub issue links
Mirror to GitHub / mirror (push) Successful in 32s
Test and Lint / backend-test (push) Successful in 1m35s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m59s
- Remove all @example.com email addresses from documentation
- Replace security@example.com with GitHub security issue links
- Replace conduct@example.com with GitHub issue link
- Update CONTRIBUTING.md to use GitHub issues instead of email
- Ensure all communication happens through GitHub's issue tracking system
- Avoid direct email communication for better transparency and tracking
2025-07-24 21:28:44 +02:00
paul 35e360dcf7 docs: add transparency note about AI-assisted development
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m30s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Has been skipped
- Add acknowledgment section about AI generation
- Clarify human testing and security auditing
- Emphasize production testing and code review
- Remove unnecessary .gitkeep files
2025-07-24 21:19:57 +02:00
paul a209796b16 refactor: complete configuration cleanup and consistency fixes
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m15s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m2s
Version and Release / version-bump (push) Failing after 1m17s
Version and Release / trigger-drone (push) Has been skipped
- Create docker-compose.dev.yml with Mailhog for development email testing
- Standardize all configurations to use PORT=3001 for backend
- Fix database service naming (postgres → db) across all files
- Add missing BACKEND_URL environment variable to all configs
- Update .env examples to match actual Docker setup requirements
- Remove orphaned postgres-init directory (Umami handles its own DB)
- Update README roadmap: mark gallery feedback as implemented, add multi-admin support
- Update deployment guide with development setup instructions
- Fix frontend Dockerfile.dev for proper hot-reload development
- Remove unused files (wedding-photos.db, frontend/README.md)

This ensures all configuration files are consistent and aligned with the deployment guide.
2025-07-24 21:13:52 +02:00
paul 7c79052681 refactor: consolidate deployment documentation and cleanup repository
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Has been skipped
- Merge all deployment docs into single comprehensive DEPLOYMENT_GUIDE.md
- Add instructions for non-nginx deployment options
- Reference utility scripts in deployment guide
- Remove orphaned migrations folder at root level
- Remove redundant deployment documentation files
- Keep all utility scripts in scripts/ folder
- Update CLAUDE.md to reference new deployment guide

This provides a single source of truth for all deployment scenarios.
2025-07-24 20:54:50 +02:00
paul d560453982 Merge main-old branch into main - includes backup service, feedback system, and numerous enhancements
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m0s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Failing after 37s
Version and Release / trigger-drone (push) Has been skipped
2025-07-24 20:21:01 +02:00
paul ecb3263267 Cleanup repository
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m21s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Has been skipped
2025-07-24 17:09:30 +02:00
paul 4d929a71ce Cleanup repository 2025-07-24 17:05:50 +02:00
paul bf705674d5 fix: multiple improvements and CI/CD updates
Frontend fixes:
- Add missing translations for chunk upload (upload.uploadingChunks, common.chunk)
- Fix photo deletion visual bug by tracking deletion state per photo
- Prevent UI confusion when deleting photos in admin grid

Backend fixes:
- Add file existence checks before deleting thumbnails
- Prevent ENOENT errors for missing thumbnail files
- Improve error handling in photo deletion

CI/CD updates:
- Remove Gitea release creation from Drone pipeline
- Simplify GitHub mirror workflow (remove history rewriting, keep file removal)
- Add clean-git-history.sh script for manual history cleanup

These changes improve the admin photo management experience and streamline
the CI/CD process for better maintainability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot fee369a503 chore: bump version to 1.0.93 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul ad75818566 fix: resolve multiple feedback management issues
- Hide "Manage Feedback" button when feedback is disabled for an event
- Fix 500 error on feedback API endpoint by adding null-safe operators
- Fix TypeError on analytics page by calculating average_rating in backend
- Fix password validation for event creation by properly awaiting async validation
- Add proper null checks and fallbacks for feedback statistics

These fixes ensure:
- Date passwords like "19.07.2025" work with simple password complexity settings
- Feedback management page loads without errors
- Analytics display correctly even with no feedback data

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 65f2c8610d chore: bump frontend version to 1.0.92 2025-07-24 16:57:08 +02:00
paul 517128fd99 fix: add missing route for feedback management page
- Added /admin/events/:id/feedback route to App.tsx
- This fixes the empty page issue when navigating to feedback management
- EventFeedbackPage component was already implemented but route was missing

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 55c8384a25 chore: bump frontend version to 1.0.91 2025-07-24 16:57:08 +02:00
paul 0064122eff feat: add feedback management enhancements
- Add German translations for event dropdown menu actions
- Add feedback settings to event edit form
- Hide comment button in gallery when feedback is disabled
- Add feedback moderation panel to event details page

Implements:
1. German translation for three dots menu actions (viewDetails, archiveEventAction, etc.)
2. Feedback enable option now visible when editing existing events
3. Comment button in photo lightbox only shows when feedback is enabled
4. Inline comment moderation in admin event detail view

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 0c783c66d0 fix: use plugins/gitea-release for Drone CI/CD
- Replace plugins/github-release with plugins/gitea-release
- Fix API endpoint compatibility issue (was using GitHub API v3)
- Update base_url to gitea.local.nothaft.cloud
- Change secret from GITHUB_TOKEN to GITEA_TOKEN
- Update release notes to reference local Gitea URLs

This fixes the 401 authentication error when creating releases
on Gitea instances.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 47e2351dab chore: bump frontend version to 1.0.90 2025-07-24 16:57:08 +02:00
paul e1aca6b00c fix: auto-convert old date formats to new date-fns syntax
- Add convertDateFormat function to automatically fix DD->dd, YYYY->yyyy
- Handles existing database values with old format strings
- Prevents RangeError when using old formats stored in settings
- Ensures backward compatibility without requiring database updates

This fix converts formats on-the-fly so existing production data
with old formats like 'DD.MM.YYYY' will work correctly.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 2cb6577f26 chore: bump frontend version to 1.0.89 2025-07-24 16:57:08 +02:00
paul c51d756503 fix: resolve date formatting error in event creation
- Fix TypeError "e.match is not a function" when creating events
- Update useLocalizedDate hook to handle both string and object date formats
- Add type safety for date format configuration
- Fix date format strings to use correct date-fns format (lowercase)
- Ensure backward compatibility with existing date settings

The issue was caused by SettingsPage saving date formats as objects
while useLocalizedDate expected strings. This fix handles both formats
gracefully and prevents the error page redirect.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot c48b9780df chore: bump frontend version to 1.0.88 2025-07-24 16:57:08 +02:00
paul 618e2695fd fix: complete restore page translations and fix structure
- Fix restoreTypes translation structure (was under options.types)
- Add missing restore.messages.restoreStarted translation
- Ensure all restore wizard strings use translations
- Add corresponding German translations for restore section
- Fix translation key structure to match component expectations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot a289f97a31 chore: bump frontend version to 1.0.87 2025-07-24 16:57:08 +02:00
paul 7387a5e9f9 fix: complete backup page translations and improve UI
- Fix '0 files' hardcoded string to use translation
- Fix 'local' destination type to show translated name
- Add missing field placeholders for rsync and S3 configurations
- Add missing backup.history.columns.* translations
- Add missing backup.history.filter.* translations
- Add missing backup.history.details.* translations
- Fix backup destination display to use proper translation key
- Replace TestTube icon with Wifi icon for connection testing
- Add all corresponding German translations
- Ensure Backup Health and Coverage titles use translations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 4615a5d795 docs: add minimum system requirements section to README
- Add CPU, RAM, and storage requirements
- Include OS and software dependencies
- Add Docker requirements for containerized deployment
- Keep it concise and focused on minimum requirements only

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul f926cd3adf fix: use plugins/github-release for Drone CI/CD
- Replace manual curl approach with plugins/github-release
- Fixes shell parsing issues with multiline strings
- Properly passes GITHUB_TOKEN via api_key setting
- Uses YAML multiline string (|) for release notes
- Cleaner and more reliable approach

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 96b05b5e0c chore: bump frontend version to 1.0.86 2025-07-24 16:57:08 +02:00
paul 99e47785e4 fix: add missing translations and fix BackupHistory useTranslation error
- Add missing useTranslation hook in BackupHistory.jsx
- Add missing translation keys:
  - backup.dashboard.health.title
  - backup.dashboard.coverage.title
  - backup.dashboard.stats.noBackupsYet
  - backup.configuration.enableBackupHelp
  - backup.configuration.schedule.options.*
  - backup.configuration.messages.*
  - backup.dashboard.noDestinationSet
  - Fix health message keys to match component usage
- Update German translations with same missing keys
- Fix runtime error preventing access to backup history page

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 94f10e1645 fix: simplify Drone github-release step to avoid shell parsing issues
- Use echo with single JSON string instead of heredoc
- Use > for folded scalar to avoid newline issues
- Properly escape quotes in JSON body
- Ensure GITHUB_TOKEN is properly passed

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot fe4a476e41 chore: bump frontend version to 1.0.85 2025-07-24 16:57:08 +02:00
paul e9f92e66d0 feat: add complete translation support for backup admin page
- Add comprehensive backup translation keys to en.json and de.json
- Update all backup components to use i18next translations:
  - BackupManagement.jsx: main page with tab navigation
  - BackupDashboard.jsx: health status and statistics
  - BackupConfiguration.jsx: settings and destination configuration
  - BackupHistory.jsx: backup history table and details
  - RestoreWizard.jsx: multi-step restore process
- Replace all hardcoded strings with translation keys
- Support dynamic values with interpolation
- Fix Drone CI/CD github-release step:
  - Write release.json to /tmp to avoid permission issues
  - Use quoted heredoc to prevent shell interpretation errors
  - Replace placeholders with actual tag values using sed

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 58f4217756 chore: bump version to 1.0.84 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul 76a466c077 fix: replace github-release plugin with direct curl API call
The github-release plugin was incorrectly detecting and using the
Gitea API instead of GitHub's API. Replaced with direct curl command
that explicitly calls GitHub API to create releases.

This approach:
- Uses curlimages/curl image for lightweight execution
- Directly calls GitHub API v3 with proper authentication
- Avoids any auto-detection issues from the plugin
- Creates releases with full markdown formatting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 9dc3777985 CRITICAL FIX: prevent gallery pages redirecting to admin login
Users were being redirected from gallery pages to admin login due to
useLocalizedDate hook trying to fetch admin settings. Fixed by:

1. Added general_date_format to public settings endpoint
2. Created publicSettingsService for unauthenticated access
3. Updated useLocalizedDate to use public settings instead of admin
4. Fixed API interceptor to not redirect on public endpoint 401s
5. Added backups/ and test-archiver/ to .gitignore

This restores gallery access for all users.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot e006d73831 chore: bump backend version to 1.0.83 2025-07-24 16:57:08 +02:00
paul 63e88c8324 CRITICAL FIX: correct email_templates column names in migration 032
Production failing because email_templates table has different columns.
Fixed column names:
- name → template_key
- subject → subject_en, subject_de
- body → body_html_en, body_html_de, body_text_en, body_text_de
- Added missing 'variables' field
- Removed language and is_active fields (not in schema)

Also fixed the down() function to use template_key instead of name.

URGENT: Production is still down.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 105167fb57 chore: bump backend version to 1.0.82 2025-07-24 16:57:08 +02:00
paul 22cc40617f fix: remove description field from migration 035 app_settings inserts
The app_settings table doesn't have a description column.
Removed all description fields to prevent migration failures.

This completes the fix for all app_settings inserts across migrations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 7f28917795 CRITICAL FIX: remove description field from app_settings inserts
Production failing with "column description does not exist" error.
The app_settings table only has: id, setting_key, setting_value, setting_type, updated_at
Removed all description fields from migration 032.

URGENT: Production is down - this is blocking the backend from starting.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 77af2a8415 chore: bump backend version to 1.0.81 2025-07-24 16:57:08 +02:00
paul 4c42b4c601 fix: remove updated_at from app_settings inserts in multiple migrations
The app_settings table in production doesn't have created_at/updated_at columns.
Fixed inconsistent usage across migrations:
- Migration 014: removed updated_at: new Date()
- Migration 027: removed updated_at: knex.fn.now()
- Migration 033: removed updated_at: new Date()

This ensures all migrations are consistent and won't fail in production.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 856cdc214c CRITICAL FIX: remove created_at/updated_at from migration 032 inserts
Production was failing because app_settings and email_templates
tables don't have created_at/updated_at columns. Removed these
fields from all insert statements to restore service.

This is a critical production fix - system was down.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 15b244292f chore: bump backend version to 1.0.80 2025-07-24 16:57:08 +02:00
paul 558a966f85 fix: force github-release plugin to use GitHub API instead of Gitea
The plugin was auto-detecting the Gitea instance and using its API
instead of GitHub's. Fixed by:
- Adding explicit environment variables to override detection
- Removing deprecated github_url/github_upload_url parameters
- Setting DRONE_REMOTE_URL to point to GitHub

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 1238db58c2 fix: remove unused formatBoolean import from migration 033
Removed unnecessary import that could cause issues if helpers.js
doesn't define formatBoolean. Migration already uses correct
boolean syntax without the helper.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 0502ed34c9 fix: remove formatBoolean calls from migration 032 - critical production fix
Migration was failing with "formatBoolean is not a function" error,
preventing backend startup. Fixed by:
- Removing formatBoolean import
- Using direct boolean values for column defaults
- Using JSON.stringify for setting values

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 1f417c7e30 chore: bump backend version to 1.0.79 2025-07-24 16:57:08 +02:00
paul a401fbdc54 fix: resolve migration conflicts and duplicate numbering
- Rename conflicting migrations to sequential numbers
- Update 035_enhance_backup_system.js to check for existing columns
- Prevent 'column already exists' errors during migration
- Add proper column existence checks before alterations
2025-07-24 16:57:08 +02:00
paul 247e154afe fix: correct GitHub repository path in Drone CI release config
- Remove deprecated base_url and upload_url parameters
- Use correct GitHub repository: the-luap/picpeak
- This should resolve the 404 error when creating releases
2025-07-24 16:57:08 +02:00
Gitea Actions Bot cbfd84ddea chore: bump version to 1.0.78 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul dc1419c051 feat: implement gallery feedback system with version tracking for backups
Gallery Feedback Features:
- Add feedback system allowing ratings, likes, comments, and favorites on photos
- Implement admin controls for enabling/disabling feedback per event
- Add content moderation with word filters and spam detection
- Implement rate limiting to prevent abuse (10 requests/15min per type)
- Create comprehensive admin interface for feedback management
- Add analytics dashboard for feedback insights
- Export feedback data when archiving events

Frontend Components:
- PhotoRating: 5-star rating system with optimistic updates
- PhotoLikes: Like/unlike with animation
- PhotoComments: Threaded comments with moderation
- PhotoFavorites: Bookmark functionality
- FeedbackSettings: Admin configuration panel
- EventFeedbackPage: Complete management interface

Backend Implementation:
- Database migration 033: 4 new tables for feedback system
- RESTful API with proper authorization
- Guest identification via SHA256(IP+UserAgent)
- Automatic backup integration
- Email notification support

Backup Version Tracking:
- Migration 034: Add version columns to backup tables
- Track app version, Node.js version, and DB schema version
- Create restore_history table for tracking restore attempts
- Add version compatibility checking for safe restores
- Configurable version matching requirements

Security & Performance:
- Input validation and sanitization
- Rate limiting per feedback type
- Content moderation system
- Optimistic UI updates
- Efficient database queries with proper indexes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 2624ea6130 fix: configure github-release plugin to use GitHub API instead of Gitea
- Add base_url and upload_url pointing to GitHub API
- Explicitly set repo and owner for GitHub repository
- Fixes 401 authentication error in release pipeline
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 08eeac66eb chore: bump version to 1.0.77 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul 11769219e4 chore: upgrade npm packages for security and stability
Backend upgrades:
- i18next: 25.3.1 → 25.3.2 (patch)
- bcrypt: 5.1.1 → 6.0.0 (maintains compatibility)
- nodemailer: 6.10.1 → 7.0.5 (no AWS SES impact)
- sharp: 0.32.6 → 0.34.3 (image processing)
- chokidar: 3.6.0 → 4.0.3 (file watching)

Frontend upgrades:
- date-fns: 2.30.0 → 4.1.0 (date utilities)
- lucide-react: 0.292.0 → 0.525.0 (icons)
- react-toastify: 9.1.3 → 11.0.5 (notifications)

All upgrades tested, 0 npm audit vulnerabilities maintained.
Deferred high-risk upgrades (archiver, React 19, Express 5).

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 7750170832 fix: update form-data and multer to address security vulnerabilities
- Update form-data from 4.0.3 to 4.0.4 (fixes CVE GHSA-fjxv-7rqg-78g4)
- Update multer from 2.0.1 to 2.0.2 (fixes CVE GHSA-fjgf-rc76-4x9p)
- Both backend and frontend now have 0 vulnerabilities
- Tested upload functionality - all working correctly

These are patch updates with no breaking changes. The updates address:
- form-data: Critical vulnerability - unsafe random function for boundary
- multer: High vulnerability - DoS via unhandled exception

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
paul 833591681a fix: remove file requirement from GitHub release in Drone CI
- Remove files parameter that was looking for non-existent CHANGELOG.md
- Update release notes to include Docker image pull commands
- Add proper formatting and quick start instructions
- Fix 'validation failed: failed to find any file to release' error

The GitHub release will now create without requiring file attachments.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot b77e60c37c chore: bump frontend version to 1.0.76 2025-07-24 16:57:08 +02:00
paul 30f6780484 fix: correct import statements for api in backup JSX files
- Change default import to named import for api from config/api.ts
- Fixes build error: 'default' is not exported by src/config/api.ts
- Affected files: BackupHistory.jsx, RestoreWizard.jsx, BackupManagement.jsx

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot aa39e132aa chore: bump version to 1.0.75 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul f6a79c815e feat: implement comprehensive backup and restore system with S3 support
- Add S3/MinIO storage adapter with multipart upload support
- Implement database backup service for SQLite and PostgreSQL
- Create backup manifest generator for tracking backup contents
- Enhance backup service with S3 integration and incremental backups
- Add restore service with safety measures and rollback capability
- Create comprehensive test suite for all backup functionality
- Add admin API endpoints for backup/restore management
- Implement frontend UI with dashboard, configuration, and restore wizard
- Add roadmap section to README with implemented backup feature

This implementation provides:
- Multiple backup destinations (local, rsync, S3/MinIO)
- Intelligent change detection to minimize backup frequency
- Full database backups with compression
- Manifest-based restore with integrity validation
- Pre-restore safety backups with rollback
- Comprehensive error handling and monitoring
- User-friendly admin interface

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:08 +02:00
Gitea Actions Bot 3c6837bd90 chore: bump version to 1.0.74 (backend + frontend) 2025-07-24 16:57:08 +02:00
paul 1773ed5f95 Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped
Original: feat: enhance security logging and ensure rate limit blocks are properly tracked

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 62dcaf8555 ci: publish images to GHCR and create GitHub release via Drone 2025-07-24 16:57:07 +02:00
paul 4ed35f1b16 chore: consolidate and update deployment documentation
- Remove completed PRODUCTION_TODO_LIST.md
- Consolidate deployment guides: keep comprehensive PRODUCTION_DEPLOYMENT_GUIDE.md, remove redundant PRODUCTION_DEPLOYMENT.md
- Update all .env.example files to reflect current system:
  - Remove deprecated ADMIN_EMAIL/ADMIN_PASSWORD (now auto-generated)
  - Add proper documentation for all environment variables
  - Clarify that Umami config is optional (primary via Admin UI)
  - Add realistic examples for SMTP providers
  - Update ports to match actual defaults (3001)
- Update PRODUCTION_DEPLOYMENT_GUIDE.md:
  - Document auto-generated admin credentials process
  - Add Traefik configuration section
  - Update security checklist with current features
  - Fix outdated environment variables
  - Add nginx proxy configuration details

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 5eff7dd4a6 chore: bump frontend version to 1.0.73 2025-07-24 16:57:07 +02:00
paul abbcdb1113 feat: exclude Claude contributor from GitHub mirror workflow 2025-07-24 16:57:07 +02:00
Gitea Actions Bot 17fc40e65d chore: bump frontend version to 1.0.72 2025-07-24 16:57:07 +02:00
paul a54a2c0fda fix: use admin API for Umami config in analytics page
- Changed from public settings endpoint to admin settings endpoint
- Fixed "Unexpected token '<'" JSON parse error
- Properly transforms settings array to key-value map
- Uses correct setting keys (analytics_umami_*)
- Maintains fallback to environment variables

The analytics page now correctly fetches Umami configuration using
the authenticated admin API instead of the public endpoint, which
was returning errors and causing JSON parse failures.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul febacb79ad feat: completely rewrite GitHub mirror to create new history from target commit
BREAKING: This completely replaces the previous approach and will DELETE
all existing history on GitHub, creating entirely new commit SHAs.

Key changes:
- Use orphan branch instead of cherry-pick to break history connection
- Create initial commit from target commit tree using git read-tree
- Apply subsequent changes as completely new commits with new SHAs
- Force push will COMPLETELY REPLACE GitHub history
- No trace of commits before 7aca927937 will remain on GitHub

This ensures GitHub shows only history from the target commit onwards
with no connection to previous commits or their metadata.
2025-07-24 16:57:07 +02:00
paul c7875102c5 fix: improve version bump workflow with better conflict resolution
- Added pre-fetch and check before committing to ensure we're up-to-date
- Improved retry logic with clearer output and better error handling
- Added explicit fetch before each retry attempt
- Use for-loop instead of while for clearer retry counting
- Better fallback from rebase to merge on conflicts
- Added set -e to fail fast on errors
- More verbose logging for debugging

This should resolve the persistent "non-fast-forward" errors by:
1. Checking if we're behind before even committing
2. Pulling changes if needed
3. Retrying with proper synchronization
4. Providing clear debug output

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 3dc013d7b1 CRITICAL FIX: Remove 403 from auth redirect logic to restore login
BREAKING ISSUE FIXED:
- 403 errors were triggering redirects, preventing login page from loading
- Public endpoints returning 403 were causing redirect loops

Changes:
- Removed 403 status from automatic redirect logic
- Only 401 (Unauthorized) now triggers login redirect
- 403 (Forbidden) errors are passed through without redirect

This fixes the critical issue where users couldn't access the login page
because public API calls were returning 403 and triggering redirects.

403 errors should be handled differently than 401:
- 401 = Missing/invalid auth (redirect to login)
- 403 = Forbidden (could be rate limit, IP block, etc - don't redirect)

🚨 Emergency fix for production

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul b7c8953cb4 fix: resolve SIGPIPE error in GitHub mirror workflow file cleanup
- Replace problematic 'find | head -20' commands that caused exit code 141
- Use 'ls -la | head -10 || true' for safer file listing
- Add better progress logging during sensitive file removal
- Add error handling with '|| true' to prevent pipe failures

The find command was outputting more than head could handle, causing
SIGPIPE when head closed the pipe early. This fix uses ls which is
more predictable and adds proper error handling.
2025-07-24 16:57:07 +02:00
paul d6adde4e09 fix: resolve GitHub mirror workflow cherry-pick failure with merge commits
- Add --no-merges flag to exclude merge commits during cherry-pick
- Improve error handling for cherry-pick conflicts with auto-resolution
- Add reporting of skipped merge commits for transparency
- Enhance logging to show detailed progress during commit application

Fixes the workflow failure caused by trying to cherry-pick merge commits
which require special handling that was causing exit code 128.
2025-07-24 16:57:07 +02:00
paul 0bf4764a07 fix: resolve CI/CD version bump race condition
- Added pull before push to handle concurrent workflow executions
- Implemented retry logic with 3 attempts for push operations
- Added fallback from rebase to merge if conflicts occur
- Added proper error handling and logging for debugging

This fixes the "non-fast-forward" error that occurs when multiple
workflows run simultaneously and try to push version bumps.

The workflow now:
1. Pulls latest changes before pushing
2. Retries up to 3 times with 5-second delays
3. Falls back to merge if rebase fails
4. Provides clear error messages for debugging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 08da01f021 feat: update GitHub mirror workflow to start history from specific commit
- Start history from commit 7aca927937 instead of orphan branch
- Use cherry-pick to preserve meaningful commit history
- Automatically exclude files that only existed before target commit
- Add comprehensive error handling and logging
- Maintain clean linear history for GitHub repository
2025-07-24 16:57:07 +02:00
paul b4b09c1650 feat: enhance mirror-to-github workflow with commit-based history filtering
- Replace orphan branch approach with commit-based filtering from cfa29ad5cb
- Add automatic removal of sensitive files (env, logs, gitea configs)
- Implement robust git operations with fallback mechanisms
- Add comprehensive debugging and error handling
- Ensure same security exclusions as manual process
2025-07-24 16:57:07 +02:00
paul b2ae5f18ad fix: handle auth errors and JSON parsing in admin panel
- Added proper HTTP status check before JSON parsing in AnalyticsPage
  * Prevents "Unexpected token '<'" error when API returns HTML error pages
  * Throws proper error for non-OK responses

- Enhanced API error handling to treat 403 as auth failure
  * Both 401 and 403 now trigger redirect to login page
  * Clears expired admin tokens automatically
  * Prevents users from staying on admin pages with expired sessions

These fixes resolve:
1. JSON parse errors when fetching Umami config
2. 403 Forbidden errors not redirecting to login
3. Backend version display issues due to auth failures

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 4a7a3bba07 chore: bump version to 1.0.71 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul b3f240b2a5 chore: add production todo list and update CI/CD configs
- Added PRODUCTION_TODO_LIST.md with 9 completed production fixes
- Updated .gitea/workflows/mirror-to-github.yml
- Updated .gitignore

This commit includes all the production fixes implemented:
1. Password complexity settings
2. Gallery login security improvements
3. Analytics configuration fixes
4. Translation additions
5. UI/UX improvements
6. Date format consistency
7. Chrome compatibility fixes

All tasks have been completed and tested for production deployment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot ba0bf11a1d chore: bump backend version to 1.0.70 2025-07-24 16:57:07 +02:00
paul d5790ad635 fix: resolve production UI and API issues
- Fixed backend version endpoint by adding retry logic import
- Gallery login page improvements:
  * Increased title size from text-xl to text-2xl (responsive scaling)
  * Title now uses event's custom primary color (var(--color-primary))
  * Removed event category badge from login page
- Fixed Umami analytics configuration check:
  * Added proper enabled state tracking
  * Warning now only shows when Umami is explicitly not configured
  * Checks both admin settings and environment variables properly

These changes improve user experience and fix false warnings in production.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot e6757bd51b chore: bump version to 1.0.69 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul 8588133a4e fix: critical database connection pool exhaustion issues
- Disabled duplicate email service (emailService.js) that was creating redundant connections
- Increased connection pool size from 10 to 25 for production environment
- Extended session timeout cache from 5 to 30 minutes to reduce DB queries
- Added connection retry logic with exponential backoff for transient failures
- Fixed password validation to use retry wrapper and correct setting key
- Updated public settings and gallery middleware to handle connection failures gracefully

These changes address the "Connection terminated unexpectedly" errors in production by:
1. Reducing unnecessary database connections
2. Increasing available connection pool capacity
3. Implementing automatic retry for transient connection failures
4. Caching frequently accessed data for longer periods

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 4b18077573 chore: bump version to 1.0.68 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul c584369d5d fix: implement 9 production enhancements and security fixes
- Password Complexity: Added 4-level complexity selector (Simple/Moderate/Strong/Very Strong) in admin security settings with dynamic backend validation
- Gallery Security: Removed event date from login page (security risk), replaced with event type badge
- Analytics Config: Fixed "Not Configured" detection logic to check both admin settings and env variables
- Analytics Accuracy: Aligned calculation logic between dashboard and analytics endpoints, added totals verification
- Translations: Added missing activity keys (analytics_settings_updated, cms_page_updated, security_settings_updated, password_reset, admin_logout, system_activity)
- UI Fixes: Fixed German text overflow in CMS page selector with proper CSS truncation
- Date Format: Event creation now respects admin-configured date format instead of browser locale
- Chrome Compatibility: Replaced emoji flags with SVG components for Windows Chrome support

All changes maintain backward compatibility and production stability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
paul 95939d57e6 fix: resolve database connection error for analytics settings
- Update publicSettings.js to handle missing analytics setting_type gracefully
- Add dedicated PUT /analytics endpoint for saving analytics settings
- Update frontend settings service to route to correct endpoints based on setting type
- Fix query to use WHERE clause that won't fail if analytics type doesn't exist

This fixes the "Connection terminated unexpectedly" error when fetching
public settings with analytics configuration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot be58146dc7 chore: bump version to 1.0.67 (backend + frontend) 2025-07-24 16:57:07 +02:00
paul 45ce98806d feat: fix analytics dashboard and implement complete Umami integration
- Fix backend analytics to include both 'download' and 'download_all' actions
- Add Analytics tab to Settings page for Umami configuration
- Update public settings endpoint to expose Umami config when enabled
- Implement dynamic Umami initialization from backend settings
- Fix frontend analytics calculations (remove hardcoded estimations)
- Add proper download counts and unique visitor tracking
- Update CLAUDE.md with production safety guidelines

The analytics dashboard now shows accurate data for all metrics, and Umami
can be configured through the admin panel instead of environment variables.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 23b7a848ab chore: bump backend version to 1.0.66 2025-07-24 16:57:07 +02:00
paul 0fe6d738b2 fix: resolve duplicate logger declaration and syntax error in rate limit service
- Remove duplicate logger import in server.js (line 26)
- Fix missing closing bracket in rateLimitService.js headers object
- Ensure backend starts without syntax errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 16:57:07 +02:00
Gitea Actions Bot 3f73d44c5a chore: bump version to 1.0.65 (backend + frontend) 2025-07-24 16:57:07 +02:00
588 changed files with 18491 additions and 70386 deletions
+8 -20
View File
@@ -4,11 +4,8 @@
# Environment
NODE_ENV=production
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker: the secrets-init service writes it to a private volume and reuses it
# across restarts). Set it explicitly only to pin your own value.
# Generate one with: openssl rand -base64 64
#JWT_SECRET=your_very_long_random_jwt_secret_here
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
@@ -41,28 +38,19 @@ NODE_ENV=production
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
#DB_PASSWORD=your_secure_postgres_password_here
DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod
# Redis Configuration
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
# IMPORTANT: Same warning applies - avoid $ or escape as $$
#REDIS_PASSWORD=your_secure_redis_password_here
REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
#ADMIN_EMAIL=admin@yourdomain.com
#ADMIN_PASSWORD=your_secure_admin_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# For Gmail: use app-specific password
-4
View File
@@ -1,4 +0,0 @@
# These are supported funding model platforms
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
buy_me_a_coffee: theluap
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- name: 💬 Discussions
url: https://github.com/PicPeak/picpeak/discussions
url: https://github.com/the-luap/picpeak/discussions
about: Ask questions and discuss with the community
- name: 🔒 Security Issues
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
about: Please review our security policy for reporting vulnerabilities
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
+9 -9
View File
@@ -42,16 +42,16 @@ Once published, images can be pulled using:
```bash
# Pull backend image
docker pull ghcr.io/picpeak/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/backend:latest
# Pull frontend image
docker pull ghcr.io/picpeak/picpeak/frontend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Pull specific version
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
```
### Using in Docker Compose
@@ -61,14 +61,14 @@ version: '3.8'
services:
backend:
image: ghcr.io/picpeak/picpeak/backend:latest
image: ghcr.io/the-luap/picpeak/backend:latest
environment:
- NODE_ENV=production
ports:
- "3001:3000"
frontend:
image: ghcr.io/picpeak/picpeak/frontend:latest
image: ghcr.io/the-luap/picpeak/frontend:latest
ports:
- "80:80"
```
@@ -86,7 +86,7 @@ spec:
spec:
containers:
- name: backend
image: ghcr.io/picpeak/picpeak/backend:latest
image: ghcr.io/the-luap/picpeak/backend:latest
imagePullPolicy: Always
```
@@ -149,8 +149,8 @@ If images aren't visible after successful push:
### View Packages
Your Docker images are available at:
- Backend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Ffrontend`
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
### Delete Old Versions
-70
View File
@@ -1,70 +0,0 @@
name: Bypass size gate
# Caps how large a PR a "review-bypass" collaborator (e.g. @Luca-Timo) can
# self-merge without a maintainer review. The branch-protection bypass list
# alone is binary — once a user is on it they can merge anything without
# review. This workflow reports a REQUIRED status check that fails when a
# bypass user's PR exceeds the configured size threshold, which blocks the
# merge even with bypass enabled. Other contributors are unaffected (the
# check reports success for them so the required-check gate doesn't trip).
#
# To tune: edit LINE_LIMIT or BYPASS_USERS below.
#
# Trigger note: uses `pull_request_target` so the workflow has the elevated
# permissions of the base repo's GITHUB_TOKEN (read PR metadata, write
# checks). The script never executes code FROM the PR — it only reads
# metadata via the API — so this is safe against fork-PR attacks.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: read
checks: write
jobs:
size-gate:
runs-on: ubuntu-latest
steps:
- name: Compute PR size and report check status
uses: actions/github-script@v7
with:
script: |
// Tune these two constants if the policy shifts.
const LINE_LIMIT = 300;
const BYPASS_USERS = ['Luca-Timo'];
const pr = context.payload.pull_request;
const author = pr.user.login;
const linesChanged = pr.additions + pr.deletions;
const filesChanged = pr.changed_files;
let conclusion, title, summary;
if (!BYPASS_USERS.includes(author)) {
// Not a bypass user — this gate doesn't apply to them. They
// go through normal review. Report success so the required
// check doesn't block their merge.
conclusion = 'success';
title = 'Not applicable';
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
} else if (linesChanged <= LINE_LIMIT) {
conclusion = 'success';
title = `OK — within bypass limit (${linesChanged} lines)`;
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
} else {
conclusion = 'failure';
title = `Too large for bypass (${linesChanged} lines)`;
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'bypass-size-gate',
head_sha: pr.head.sha,
status: 'completed',
conclusion,
output: { title, summary }
});
+13 -44
View File
@@ -1,8 +1,7 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/stable branches (main → ':main' rolling tag for active-dev
# builds; stable → ':stable' + ':latest' for the curated channel)
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
@@ -21,10 +20,10 @@ name: Build and Push Docker Images
on:
push:
branches: [ main, stable ]
branches: [ main, beta ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main, stable ]
branches: [ main, beta ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
@@ -38,16 +37,6 @@ on:
- 'true'
- 'false'
# Once release-please authors releases with a PAT (#719), a new version fires
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
# suppress them). They build the same immutable version, so collapse them into a
# single run by grouping on the ref. Branch and PR builds use different refs and
# still run independently; a superseding push cancels an in-flight run for the
# same ref (only the newest build per ref is kept).
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
@@ -145,10 +134,7 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }},ignore-error=true
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
@@ -256,9 +242,7 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -283,14 +267,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
@@ -381,10 +360,7 @@ jobs:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
# ignore-error: a flaky GitHub Actions cache write ("error writing
# layer blob: not_found") must not fail an otherwise-successful build
# that already pushed the image.
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }},ignore-error=true
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
@@ -473,9 +449,7 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -500,14 +474,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
+17 -11
View File
@@ -16,17 +16,24 @@ name: Fresh-install smoke
# don't pay the build cost.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
# required check "missing" forever and block the merge. Better to
# pay the boot cost on every PR than maintain a per-path allowlist
# that drifts as the install surface evolves. (Branches also updated
# post-#669 rename: beta → main, old main → stable.)
push:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
pull_request:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
workflow_dispatch:
permissions:
@@ -54,8 +61,7 @@ jobs:
load: true
tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-smoke
# ignore-error: a flaky GHA cache write must not fail the build.
cache-to: type=gha,mode=max,scope=install-smoke,ignore-error=true
cache-to: type=gha,mode=max,scope=install-smoke
- name: Create Docker network
run: docker network create picpeak-smoke
-36
View File
@@ -1,36 +0,0 @@
name: PR Title Lint
# Release Please derives version bumps and the changelog from Conventional
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
# without a version bump or a changelog entry. This check fails a PR whose
# title is not a valid Conventional Commit so the release stays automated.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
lint-pr-title:
runs-on: ubuntu-latest
steps:
- name: Validate PR title is a Conventional Commit
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
revert
docs
style
chore
refactor
test
build
ci
+3 -54
View File
@@ -2,7 +2,7 @@ name: Release Please (Beta)
on:
push:
branches: [main]
branches: [beta]
permissions:
contents: write
@@ -20,48 +20,10 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
# A dedicated token (fine-grained PAT) makes the release PR run CI
# automatically (no "workflows awaiting approval") and lets it be
# merged without a manual review. Falls back to GITHUB_TOKEN so the
# workflow still works before the secret is added (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: main
# Auto-approve + enable auto-merge on the open release PR so betas publish
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
# a valid review (requires the org's "Allow GitHub Actions to approve pull
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
# set: without it the PR is bot-authored and can't be self-approved, so we
# skip and leave today's manual flow. Best-effort — never blocks the run.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# This job has no checkout, so gh can't infer the repo from a git
# remote — set it explicitly (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--main --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
# than the PR author (the PAT) — so it counts as a valid review.
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
# Enable auto-merge as the PAT so the eventual merge commit is
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
# push is suppressed by recursion prevention and the follow-up run that
# cuts the tag/release never fires (#719).
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
target-branch: beta
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -73,16 +35,3 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+2 -44
View File
@@ -2,7 +2,7 @@ name: Release Please
on:
push:
branches: [stable]
branches: [main]
permissions:
contents: write
@@ -20,39 +20,10 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
# Dedicated token so the release PR runs CI + can auto-merge without a
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
@@ -63,16 +34,3 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+12 -9
View File
@@ -30,17 +30,20 @@ name: Schema drift (#530)
# the same shape is caught before merge.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
# trigger that skipped on unrelated PRs would leave the required
# check "missing" forever, blocking every PR that doesn't touch
# migrations. The ~75-second cost on every PR buys an unconditional
# safety net. (Branches also updated post-#669 rename: beta → main,
# old main → stable.)
push:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
pull_request:
branches: [main, stable]
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
workflow_dispatch:
permissions:
+2 -2
View File
@@ -60,9 +60,9 @@ jobs:
# integration/webhookDelivery — supertest fixture
# services/backupService.enhanced — knex mock chain
# routes/__tests__/adminAuth — supertest fixture
# (adminNotifications was excluded; #597 fix re-enables it.)
# routes/__tests__/adminNotifications — supertest fixture
npx jest \
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \
--testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth|routes/__tests__/adminNotifications' \
--ci
frontend:
-98
View File
@@ -1,98 +0,0 @@
# What's New highlights — GitHub Models release step (reusable)
#
# Called by the release-please workflows AFTER a release is created
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
# as a job in the SAME workflow run rather than on its own `release: published`
# trigger, because release-please creates the release with the default
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
# events — a standalone `release:` workflow would simply never fire.
#
# What it does: condenses the new release's "### Features" into <=8 short
# bullets via GitHub Models (free tier, `models: read`) and injects a
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
# Features list for releases without it — so this is purely a quality upgrade,
# never a hard dependency. Failure is isolated by `continue-on-error` + the
# deterministic fallback below, so it can never break a release.
#
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
# step fails soft (continue-on-error) and the deterministic fallback produces
# the bullets instead — the feature works either way, Models just polishes them.
#
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
# real release notes; app parseWhatsNew() reads the block back).
name: What's New highlights
on:
workflow_call:
inputs:
tag:
description: Release tag to annotate (e.g. v2.3.0)
required: true
type: string
jobs:
highlights:
runs-on: ubuntu-latest
permissions:
contents: write # to edit the release body
models: read # GitHub Models (free tier)
# GH_REPO at job scope so every `gh` call targets the right repo without
# needing an actions/checkout step. Without this, `gh` falls back to
# parsing `.git/config` in the runner's empty workspace and dies with
# "fatal: not a git repository" — which hard-fails the whole job before
# any continue-on-error can save it.
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Extract Features from the published release
id: feat
# Belt-and-braces: the job-level comment says "never let highlights
# break a release", but the original wiring only marked the AI +
# inject steps as continue-on-error. A hiccup here (rate limit,
# transient API error) would still hard-fail the job. Match the
# design intent and fail soft.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
run: |
BODY=$(gh release view "$TAG" --json body -q .body)
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
- name: Summarize with GitHub Models
if: ${{ steps.feat.outputs.features != '' }}
id: ai
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
uses: actions/ai-inference@v1
with:
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
system-prompt: >
You write release highlights for the admins of a self-hosted
photo-gallery + CRM app. Given raw changelog "Features" lines, output
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
no jargon, no issue numbers. One bullet per distinct user-visible
feature. Output ONLY "- " bullets, nothing else.
prompt: ${{ steps.feat.outputs.features }}
- name: Inject the What's New block
if: ${{ steps.feat.outputs.features != '' }}
continue-on-error: true # never let highlights break a release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
AI: ${{ steps.ai.outputs.response }}
FEATURES: ${{ steps.feat.outputs.features }}
run: |
BULLETS="$AI"
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
if [ -z "$BULLETS" ]; then
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
fi
BODY=$(gh release view "$TAG" --json body -q .body)
# Idempotent: strip any prior block before re-injecting.
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
-3
View File
@@ -129,6 +129,3 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.82.4-beta.0"
".": "3.57.0-beta.0"
}
-599
View File
@@ -5,605 +5,6 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07)
### Bug Fixes
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f))
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81))
* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb))
## [3.82.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.2-beta.0...v3.82.3-beta.0) (2026-07-06)
### Bug Fixes
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c))
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d))
* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4))
## [3.82.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.1-beta.0...v3.82.2-beta.0) (2026-07-05)
### Bug Fixes
* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79))
* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c))
* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0))
## [3.82.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.0-beta.0...v3.82.1-beta.0) (2026-07-05)
### Bug Fixes
* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99))
* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216))
## [3.82.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.81.0-beta.0...v3.82.0-beta.0) (2026-07-03)
### Features
* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994))
* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e))
## [3.81.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.80.0-beta.0...v3.81.0-beta.0) (2026-07-03)
### Features
* admin two-factor authentication (TOTP) with recovery codes + CLI reset ([cf07361](https://github.com/PicPeak/picpeak/commit/cf073615effa8a91e19374ad3e9924e6e7322950))
* **admin-ui:** TOTP MFA enrollment + two-step login; remove stub 2FA toggle ([96e3c68](https://github.com/PicPeak/picpeak/commit/96e3c68b9d6b35a82abcad664a6da7b19150b4fd))
* **auth:** admin TOTP MFA — enrollment, login challenge, recovery, CLI reset ([72e2ef6](https://github.com/PicPeak/picpeak/commit/72e2ef6721b0572ed34455de901aa357eacd8c76))
### Bug Fixes
* event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders ([b187f58](https://github.com/PicPeak/picpeak/commit/b187f588b4d12af7a7849f8558c0085573d4af76))
* **security:** close cross-event thumbnail leak, bulk-op ownership bypass, + hardening ([081f3ed](https://github.com/PicPeak/picpeak/commit/081f3edcdffc65a77000cc638e364ea9dc03767f))
* **security:** cross-event thumbnail leak, bulk-op ownership bypass + auth hardening ([b732974](https://github.com/PicPeak/picpeak/commit/b732974779803b67097c81ae6bce2de0f2910794))
## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03)
### Features
* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a))
* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23))
* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113))
* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777))
### Bug Fixes
* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2))
* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b))
## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02)
### Bug Fixes
* **settings:** remove duplicate Mail import that broke the dev server ([5b535f8](https://github.com/PicPeak/picpeak/commit/5b535f86580275eda768fa2d85a8c94bd701f832))
* **settings:** remove duplicate Mail import that crashes the dev server ([4aa6583](https://github.com/PicPeak/picpeak/commit/4aa6583baef55e2c12e9cde7d391156436de518f))
## [3.79.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.78.0-beta.0...v3.79.0-beta.0) (2026-07-02)
### Features
* setup wizard + argument-driven unattended install ([681619f](https://github.com/PicPeak/picpeak/commit/681619f0a14070309342a9f908a5bbc8a57d47d8))
* **setup:** step-by-step wizard + argument-driven unattended install ([d35c413](https://github.com/PicPeak/picpeak/commit/d35c413651bc10f177a683a8057ad92c03b1cf00))
## [3.78.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.3-beta.0...v3.78.0-beta.0) (2026-07-02)
### Features
* zero-config first run — in-browser admin bootstrap + auto-generated secrets ([bafc96f](https://github.com/PicPeak/picpeak/commit/bafc96f468e3b5cca2ec3291e7b568886755099d))
### Bug Fixes
* **ci:** enable release-PR auto-merge with the PAT, not GITHUB_TOKEN ([e08a33d](https://github.com/PicPeak/picpeak/commit/e08a33d9ea273dc18877743f71f59d64bfc3dfb5))
* enable release-PR auto-merge with the PAT so releases actually publish ([97b9853](https://github.com/PicPeak/picpeak/commit/97b9853709fb59a900d70bb2a6bf365d98ae4f86))
## [3.77.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.2-beta.0...v3.77.3-beta.0) (2026-07-02)
### Bug Fixes
* set GH_REPO in release-please auto-merge step ([d00d52a](https://github.com/PicPeak/picpeak/commit/d00d52a2215dfcae34086cf3e10fe4da0aef09c9))
## [3.77.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.1-beta.0...v3.77.2-beta.0) (2026-07-02)
### Bug Fixes
* auto-publish release-please PRs without manual approval ([fb64ec0](https://github.com/PicPeak/picpeak/commit/fb64ec0910f8c3ecffb40d85e4f3a08f73503671))
* **ci:** auto-publish release-please PRs without manual approval ([#719](https://github.com/PicPeak/picpeak/issues/719)) ([a3e7232](https://github.com/PicPeak/picpeak/commit/a3e7232b8ed012b8449a76d3e4ea3c5daddd5514))
## [3.77.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.0-beta.0...v3.77.1-beta.0) (2026-07-02)
### Documentation
* require screenshots for UI changes in PRs ([f5b4aa7](https://github.com/PicPeak/picpeak/commit/f5b4aa7a5bc321ffbd33f1c1b92003435a7ee842))
* require screenshots for UI changes in PRs ([8ca7477](https://github.com/PicPeak/picpeak/commit/8ca74776f4d3f7be930a713afe4ac4de594adedd))
## [3.77.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.2-beta.0...v3.77.0-beta.0) (2026-07-01)
### Features
* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([e873f7c](https://github.com/PicPeak/picpeak/commit/e873f7c98ce108b090d70a3b7df2d2929699e997))
* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([6f95796](https://github.com/PicPeak/picpeak/commit/6f95796b7c19829197eaff0d4934ad9b84d0e2f3))
## [3.76.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.1-beta.0...v3.76.2-beta.0) (2026-06-30)
### Bug Fixes
* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([3feed0f](https://github.com/PicPeak/picpeak/commit/3feed0fae6a5792a7192a529942e08d9872b7e6e))
* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([2a5f0a8](https://github.com/PicPeak/picpeak/commit/2a5f0a8601ba5cb28243b39278ecdc0892388a96))
## [3.76.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.0-beta.0...v3.76.1-beta.0) (2026-06-30)
### Bug Fixes
* **whatsnew:** decode HTML entities and trim em-dash detail in fallback bullets ([5582644](https://github.com/PicPeak/picpeak/commit/5582644dc49330549be2a3a4cdd5b1ba0f21a294))
## [3.76.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.75.1-beta.0...v3.76.0-beta.0) (2026-06-30)
### Features
* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a0f7033](https://github.com/PicPeak/picpeak/commit/a0f7033ffc812f92d56e2eac7bd2f498b95ef83b))
## [3.75.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.75.0-beta.0...v3.75.1-beta.0) (2026-06-30)
### Bug Fixes
* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([25bf7bb](https://github.com/PicPeak/picpeak/commit/25bf7bb5239420da078749bac270196df6968581))
* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([1b8747d](https://github.com/PicPeak/picpeak/commit/1b8747dc82763ba6b4da3a55045cab8740da2a13))
## [3.75.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.74.0-beta.0...v3.75.0-beta.0) (2026-06-30)
### Features
* **updates:** "What's New" highlights after update + pre-update teaser ([a1a73bf](https://github.com/PicPeak/picpeak/commit/a1a73bf75ff3fcd0833fdf7922a35ad09f19439b))
* **updates:** "What's New" highlights after update + pre-update teaser ([500cf85](https://github.com/PicPeak/picpeak/commit/500cf8522e556575bd74d4c71d38a83fb2596b5e))
### Documentation
* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([3528f6b](https://github.com/PicPeak/picpeak/commit/3528f6b8b7e2b537b111f7787d48459a976ef744))
* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([748238e](https://github.com/PicPeak/picpeak/commit/748238e8caf198e3899954804e61a2e179058957))
## [3.74.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.73.0-beta.0...v3.74.0-beta.0) (2026-06-29)
### Features
* **admin:** in-app migration banner for the org rename ([0213347](https://github.com/PicPeak/picpeak/commit/02133478bd2684c562d11cc122cf5059832ff76a))
* **admin:** in-app migration banner for the org rename ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([2a4bf3b](https://github.com/PicPeak/picpeak/commit/2a4bf3b868c6733d0b865c8c0e977ba84d6e6453))
### Documentation
* branch model + migration-to-org guide + PR template ([166ef47](https://github.com/PicPeak/picpeak/commit/166ef47611a248c4d517e26d390d87d21f077ca1))
* branch model + migration-to-org guide + PR-template target hint ([d606fcd](https://github.com/PicPeak/picpeak/commit/d606fcd5a425fed3c968ec06b071a386bf558c28))
* prominent migration banner at the top of README ([14bd3e1](https://github.com/PicPeak/picpeak/commit/14bd3e1a6c6cf74378d6f316024584d8941cbcd5))
* prominent migration banner at the top of README ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([5839bba](https://github.com/PicPeak/picpeak/commit/5839bba72a56cc29077f63f7daa038995fb09dfb))
## [3.73.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.72.0-beta.0...v3.73.0-beta.0) (2026-06-29)
### Features
* **dashboard:** revenue "year" tile toggles 365 days ↔ calendar YTD ([d1c9e02](https://github.com/the-luap/picpeak/commit/d1c9e02bcf50b6c08eebc85acdbfba29bfee84ac))
* **invoices:** surface monthly/manual accumulator drafts in the Bills list ([e457656](https://github.com/the-luap/picpeak/commit/e457656b9d06bb420c9d0985fe15c30d6c88aed9))
### Bug Fixes
* **invoices:** add bank transfer to the mark-paid method list ([e96ef4c](https://github.com/the-luap/picpeak/commit/e96ef4c5a35bc9e575bc3419fb318a3ee9df1bd6))
* **invoices:** badge held (unsent, no send date) invoices as "Draft" ([e4367e0](https://github.com/the-luap/picpeak/commit/e4367e028a5228ef50c4bbd522d0777bc7340b52))
* **invoices:** show "Draft" on the invoice detail page for accumulator drafts ([ca09442](https://github.com/the-luap/picpeak/commit/ca0944293f66b6465a577340e63d592598915092))
* **reminders:** wrap is_active/is_archived wheres in formatBoolean ([b9d9138](https://github.com/the-luap/picpeak/commit/b9d91385b43de7ede508884f7cf78b5cf785f853))
## [3.72.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.3-beta.0...v3.72.0-beta.0) (2026-06-28)
### Features
* **workflows:** booking cutover — wire booking actions + hold documents behind approval gates ([ec33ec7](https://github.com/the-luap/picpeak/commit/ec33ec7670a4feb1108d1bcbfe34727f63cc8cf9))
### Bug Fixes
* **workflows:** defer quote.accepted/declined emit until the 15-min response window locks ([539a837](https://github.com/the-luap/picpeak/commit/539a83711d1996dc9c262365f2c511e7bc445add))
* **workflows:** make the dashboard pending-approvals card items clickable too ([6e20d58](https://github.com/the-luap/picpeak/commit/6e20d58487c5e20b08e1d1b4ddd4e76f9e922a79))
## [3.71.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.2-beta.0...v3.71.3-beta.0) (2026-06-27)
### Bug Fixes
* **events:** wire customer notifications into both public API entry points ([#647](https://github.com/the-luap/picpeak/issues/647)) ([f017542](https://github.com/the-luap/picpeak/commit/f01754247cdb94c5935ad5abbda116841f6c7fba))
## [3.71.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.1-beta.0...v3.71.2-beta.0) (2026-06-27)
### Bug Fixes
* event-reminder, email-language & gallery-publish bugs surfaced during workflow testing ([c8714ca](https://github.com/the-luap/picpeak/commit/c8714ca42f4d82d50fe611b2a630260ebecbe740))
## [3.71.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.0-beta.0...v3.71.1-beta.0) (2026-06-26)
### Bug Fixes
* **admin:** stack publish-gallery dialog CTAs so the German label fits ([#670](https://github.com/the-luap/picpeak/issues/670)) ([748af98](https://github.com/the-luap/picpeak/commit/748af98f3d3f8c00695b82e94d741a0e10a39a81))
## [3.71.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.70.0-beta.0...v3.71.0-beta.0) (2026-06-25)
### Features
* admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome) ([15be3b8](https://github.com/the-luap/picpeak/commit/15be3b8d32965eedc08d46ccc525c65a3bf34de6))
* **workflows:** per-quote booking-workflow picker + quote→invoice (no gallery) built-in ([d14f1d8](https://github.com/the-luap/picpeak/commit/d14f1d850cc995b2cb1119ba0424f123feba50ec))
* **workflows:** pre-event reminder picks the template GROUP on the block, type stays automatic ([10d091b](https://github.com/the-luap/picpeak/commit/10d091b55e0c44738b4001a71def6416a8f0aeb0))
* **workflows:** route webhook node through the delivery pipeline (full Option 1) ([675e41a](https://github.com/the-luap/picpeak/commit/675e41a2f72c8c23fa5c36b13bc6b95abcb9d570))
* **workflows:** warn when disabling a built-in (reverts to legacy, not off) ([c5f131c](https://github.com/the-luap/picpeak/commit/c5f131cec32826331722ef3705c5f5422e31726d))
### Bug Fixes
* **crm:** pre-event reminder resolves recipient from the event row, not a non-existent column ([5fbe514](https://github.com/the-luap/picpeak/commit/5fbe514db6e386eee2eeade548bccbb5bbc5b422))
* **event-types:** renaming a type's slug cascades to events, quotes + reminder template ([415c93a](https://github.com/the-luap/picpeak/commit/415c93a512f74898d0225ce2e9298f24cc12f60d))
* **workflows:** close review blockers — prefetch-safe approvals + loud gate-edge failure ([98ab717](https://github.com/the-luap/picpeak/commit/98ab717043e3fdefac0934bf8f4621d523b15e9a))
* **workflows:** harden graph validation + refuse enabling unimplemented flows ([d927464](https://github.com/the-luap/picpeak/commit/d927464778272bd862aa01903179672f4d47368a))
* **workflows:** matchFilter strict equality + accurate comment ([dee8d40](https://github.com/the-luap/picpeak/commit/dee8d40bb3235a908bba514a97a62d3a91a6e131))
* **workflows:** ship built-ins disabled for first beta + enabled-based mutex + admin sentinel ([5893ecb](https://github.com/the-luap/picpeak/commit/5893ecb27a0365a79ec04336c5a122b31d31db0e))
* **workflows:** wire a real, SSRF-guarded webhook action (was a silent no-op) ([af7eea8](https://github.com/the-luap/picpeak/commit/af7eea8b43e37905a79138bcde4b1026dea13050))
## [3.70.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.1-beta.0...v3.70.0-beta.0) (2026-06-23)
### Features
* **analytics:** pluggable trackers — Umami + Rybbit + Custom ([#663](https://github.com/the-luap/picpeak/issues/663) Phase 1) ([83461fe](https://github.com/the-luap/picpeak/commit/83461fe5d4d44006482167464d92e70546cf7377))
## [3.69.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.0-beta.0...v3.69.1-beta.0) (2026-06-23)
### Bug Fixes
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([349f566](https://github.com/the-luap/picpeak/commit/349f566e87b33c59f61eb28b8abc5f889e6285d6))
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([7534447](https://github.com/the-luap/picpeak/commit/7534447b6c0df4290fd8dac12270673097096f1b))
## [3.69.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.68.1-beta.0...v3.69.0-beta.0) (2026-06-22)
### Features
* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/the-luap/picpeak/issues/655)) ([3ac7017](https://github.com/the-luap/picpeak/commit/3ac70177efc237b8169278208983b0de3629bc72))
* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/the-luap/picpeak/issues/655)) ([f2814e4](https://github.com/the-luap/picpeak/commit/f2814e4a4ce3aa9affc232243d615a15a1aae0c0))
### Bug Fixes
* **i18n:** replace ASCII quote with U+201D in DE perGuestLimitsDesc ([98e97e3](https://github.com/the-luap/picpeak/commit/98e97e3cf214c96cdefd99bfedd6724f0b85c41c))
## [3.68.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.68.0-beta.0...v3.68.1-beta.0) (2026-06-22)
### Bug Fixes
* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/the-luap/picpeak/issues/654)) ([6193ab7](https://github.com/the-luap/picpeak/commit/6193ab7f6aafd94b6e2e432ddf170361fd306d4e))
* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/the-luap/picpeak/issues/654)) ([b1bfd48](https://github.com/the-luap/picpeak/commit/b1bfd4838e7104e4f85695e180b20222206073ac))
* **test:** raise bootCrmDb beforeAll timeout on slideshow suites ([f4b6b89](https://github.com/the-luap/picpeak/commit/f4b6b8941a30a20615cc87627a0663ff6d03c932))
## [3.68.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.67.1-beta.0...v3.68.0-beta.0) (2026-06-21)
### Features
* **whatsapp:** admin-selectable template parameters + reorder ([#647](https://github.com/the-luap/picpeak/issues/647) follow-up) ([80e8ec5](https://github.com/the-luap/picpeak/commit/80e8ec5bc71f0653d56f1087521f5207aee0ba8f))
## [3.67.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.67.0-beta.0...v3.67.1-beta.0) (2026-06-21)
### Bug Fixes
* **branding+whatsapp:** preserve customCss through preset switches ([#645](https://github.com/the-luap/picpeak/issues/645)) + admin-pinned WhatsApp template language ([#647](https://github.com/the-luap/picpeak/issues/647)) ([cde028e](https://github.com/the-luap/picpeak/commit/cde028e9199a9ddb09957a87590732f4bd4d7a7b))
## [3.67.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.66.1-beta.0...v3.67.0-beta.0) (2026-06-21)
### Features
* Live Slideshow ("Diashow") — fullscreen, auto-updating projector view for live events ([4356393](https://github.com/the-luap/picpeak/commit/4356393b4433dd6b4147388688766b9464294c89))
* **slideshow:** add image fit setting (fill vs black bars) ([b5c73e0](https://github.com/the-luap/picpeak/commit/b5c73e05bd41b262f864e8c700b1d38582b3817f))
* **slideshow:** admin ui for live slideshow ([385b05a](https://github.com/the-luap/picpeak/commit/385b05adcf6a4acb7939e372328a55df7dae5e08))
* **slideshow:** backend api for live slideshow ([dea5e0f](https://github.com/the-luap/picpeak/commit/dea5e0f8a6421c056868c2d9bea11e5bf1ee106a))
* **slideshow:** db columns for live slideshow ([1029dd0](https://github.com/the-luap/picpeak/commit/1029dd05bdb9ca0a97ad86100145221850648651))
* **slideshow:** en/de strings for live slideshow ([cb761ee](https://github.com/the-luap/picpeak/commit/cb761ee621aa553cf210c4224b6cbbf7bf2ef0cb))
* **slideshow:** gate behind a feature flag + move globals to a Settings tab ([69367b4](https://github.com/the-luap/picpeak/commit/69367b45be1c13d87e73e72da34a1f41a5849dfe))
* **slideshow:** public fullscreen slideshow viewer ([fd02254](https://github.com/the-luap/picpeak/commit/fd02254f78bd1860780355ebaa68293d58ce18b3))
### Bug Fixes
* **slideshow:** deny display-only token on download/upload/feedback (PR [#646](https://github.com/the-luap/picpeak/issues/646) review) ([e36b330](https://github.com/the-luap/picpeak/commit/e36b3309ca66404d189d5b218cc1f0eba925e4c7))
* **slideshow:** dip-to-white/black no longer flickers the image ([db8388c](https://github.com/the-luap/picpeak/commit/db8388c79e44f5d254d984810bd62bfb11effd0f))
* **slideshow:** drop updated_at from event writes ([1e40f82](https://github.com/the-luap/picpeak/commit/1e40f8296ca59ff0395f6cc09ee452ab62653cdc))
* **slideshow:** feature flag is a master kill-switch, not just admin UI ([759784a](https://github.com/the-luap/picpeak/commit/759784a4d1cfe7e67c825293760169ad6904f090))
* **slideshow:** fill the viewport instead of black bars ([6ec46de](https://github.com/the-luap/picpeak/commit/6ec46de0e7bb821ea4e4a7fc2318792b810c3f36))
* **slideshow:** read globals from app_settings, not the missing settings table ([0f4388d](https://github.com/the-luap/picpeak/commit/0f4388d68ab85049c46e7af566d35f4fbf6e4d02))
* **slideshow:** surface backend error in the live slideshow card ([056f938](https://github.com/the-luap/picpeak/commit/056f9381de5dbe90243bea409b587b4910050cbf))
### Performance Improvements
* **slideshow:** cache global settings to cut /state DB reads (PR [#646](https://github.com/the-luap/picpeak/issues/646) review) ([a995131](https://github.com/the-luap/picpeak/commit/a995131f4266e112c96c6e8cedd5158995ebe899))
### Documentation
* **slideshow:** add Live Slideshow guide + README entries ([16013d1](https://github.com/the-luap/picpeak/commit/16013d1cf9ad82ee052f905f9702feffde7b67eb))
## [3.66.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.66.0-beta.0...v3.66.1-beta.0) (2026-06-19)
### Bug Fixes
* **deps:** bump qs/brace-expansion overrides + add uuid override for node-cron ([d705059](https://github.com/the-luap/picpeak/commit/d705059d3c2904184f037bbe0208fe128fdb9b63))
* **security:** close BOLA on photo-export + NAT64 SSRF in URL guard ([b8211e9](https://github.com/the-luap/picpeak/commit/b8211e9944da9e7b1c43a25e2f24c8a2425000cf))
* **security:** close NAT64 SSRF + photo-export BOLA + sweep Trivy alerts (GHSA-wmjx-pc37-272r, GHSA-9v4w-jrhx-g5wr) ([6f40db8](https://github.com/the-luap/picpeak/commit/6f40db859751efc2c931bc981a48148808fd3701))
## [3.66.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.65.1-beta.0...v3.66.0-beta.0) (2026-06-19)
### Features
* **categories:** per-category download permissions ([#640](https://github.com/the-luap/picpeak/issues/640) part B) ([820f483](https://github.com/the-luap/picpeak/commit/820f4835f1f5a41cbef6816c387ef9ec3dafd526))
* **common:** generic Promise-based ConfirmDialog primitive ([#640](https://github.com/the-luap/picpeak/issues/640) part C) ([a3fcb5b](https://github.com/the-luap/picpeak/commit/a3fcb5bc9e82849ebe1f55620e8aa7e60ccd973f))
* **feedback:** export shape toggle — per-action vs per-guest pivot ([#640](https://github.com/the-luap/picpeak/issues/640) part E) ([fabd67a](https://github.com/the-luap/picpeak/commit/fabd67aecd6caf308956e5b4cb9df7dd44452142))
* **whatsapp:** WhatsApp Business API notification channel ([#640](https://github.com/the-luap/picpeak/issues/640) part D) ([78c8e9d](https://github.com/the-luap/picpeak/commit/78c8e9d9f91d56e07e04df4ed90fb05ccdfb69d2))
### Bug Fixes
* **archives:** stream-extract restore for &gt;2 GiB + preserve original_filename via manifest ([#640](https://github.com/the-luap/picpeak/issues/640)) ([e4e79a0](https://github.com/the-luap/picpeak/commit/e4e79a0b3a6d3ddbbc2f3cebdcadc89307147248))
* **i18n:** wrap WhatsApp token show/hide aria-label through t() ([a8bb7b4](https://github.com/the-luap/picpeak/commit/a8bb7b439f6f57af9653ce283c951070bd52f3c2))
* **settings:** hoist tab-visibility useEffect above isLoading early return ([49bfb45](https://github.com/the-luap/picpeak/commit/49bfb45332993b919ad4f949a0cd912a85888620))
## [3.65.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.65.0-beta.0...v3.65.1-beta.0) (2026-06-18)
### Bug Fixes
* **i18n:** sweep activity-type translations + Events / API Tokens / Webhooks settings tabs ([f17c654](https://github.com/the-luap/picpeak/commit/f17c654e146683683f347ae2cd46de9cf3e47989))
## [3.65.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.64.0-beta.0...v3.65.0-beta.0) (2026-06-18)
### Features
* **accounting:** consolidate VAT/financial config into Settings → Accounting ([dc7b87b](https://github.com/the-luap/picpeak/commit/dc7b87bb874e22ac6902fd2d48531ecdb6108c88))
* **accounting:** explain dispositions inline, drop markup from pass-through ([9a023c0](https://github.com/the-luap/picpeak/commit/9a023c019750ebcd8d21e005aaf9a77a32cb34a3))
* **accounting:** incoming-invoice workflow v2 + VAT/financial settings consolidation ([b527915](https://github.com/the-luap/picpeak/commit/b5279155ea4c545e13bab8bde46a39cfccf107fe))
* **accounting:** invoices force-enable the Accounting master ([51837c3](https://github.com/the-luap/picpeak/commit/51837c3a88f711b164fafe2c7677e1a91c7542f9))
* **accounting:** re-categorize incoming invoices, note field, pending re-bill pool ([36a8e42](https://github.com/the-luap/picpeak/commit/36a8e42f90f15a1ba96d9c4f004fa542d33e4937))
* **accounting:** supplier-country tax default + configurable default output VAT code ([267b121](https://github.com/the-luap/picpeak/commit/267b121d66994bc57b10cd0694ab0e4320b163d9))
### Bug Fixes
* **accounting:** address the-luap PR [#636](https://github.com/the-luap/picpeak/issues/636) review ([707c5d0](https://github.com/the-luap/picpeak/commit/707c5d027798bdafb9fe09d7efcbd9ea65330076))
* **accounting:** tax-report storno totals + hours-line date on Postgres ([db9e41d](https://github.com/the-luap/picpeak/commit/db9e41d19846b31b29c5c1be2ee06a7958bb43b0))
* **crm:** editor totals box computed VAT 100× too small ([e9b297c](https://github.com/the-luap/picpeak/commit/e9b297c162a19da31d53de377b90bfd5cda1b0a7))
* **hours:** move logActivity out of the entry transactions (SQLite deadlock) ([348955b](https://github.com/the-luap/picpeak/commit/348955b261713fc9f0b48391a1d4117f6f8c873f))
## [3.64.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.63.0-beta.0...v3.64.0-beta.0) (2026-06-18)
### Features
* **admin/exports:** inline preview modal with copy-to-clipboard ([#631](https://github.com/the-luap/picpeak/issues/631)) ([fc5c1ae](https://github.com/the-luap/picpeak/commit/fc5c1ae93f87678fcc16bc84a14a60a59b1a3c7b))
* **admin/exports:** inline preview modal with copy-to-clipboard ([#631](https://github.com/the-luap/picpeak/issues/631)) ([27b5f7e](https://github.com/the-luap/picpeak/commit/27b5f7e4b68e43345cd99dd5cc77308dcd7ec98b))
## [3.63.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.62.0-beta.0...v3.63.0-beta.0) (2026-06-17)
### Features
* **events:** duplicate-gallery action ([#626](https://github.com/the-luap/picpeak/issues/626)) ([e985d25](https://github.com/the-luap/picpeak/commit/e985d25207671cbfefcdda9775a96eadf2fe0698))
### Bug Fixes
* **events:** publish-from-draft email carries the real password ([#627](https://github.com/the-luap/picpeak/issues/627)) ([83b568e](https://github.com/the-luap/picpeak/commit/83b568ee2ddc007b7d981fd4b46b69810f0165c3))
* **gallery:** admin edits to welcome_message land for returning guests ([#625](https://github.com/the-luap/picpeak/issues/625)) ([ea6245c](https://github.com/the-luap/picpeak/commit/ea6245cfdea67bd4668e2100f295433a3d29f7f1))
* **upload:** auto-throttle on low-memory hosts + correct documented RAM minimum ([#628](https://github.com/the-luap/picpeak/issues/628)) ([714a9f6](https://github.com/the-luap/picpeak/commit/714a9f6fb1f48ba1316cc240054d5128749581d8))
## [3.62.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.61.0-beta.0...v3.62.0-beta.0) (2026-06-17)
### Features
* **accounting:** add a Banana "Income & Expense" (cash-book) export format ([445d6d7](https://github.com/the-luap/picpeak/commit/445d6d7b6d6b0692d5b7c0a3dd0ca8b71dfad6ef))
* **accounting:** bill editor VAT dropdown + GET returns vat_code snapshot ([2479d87](https://github.com/the-luap/picpeak/commit/2479d87afc5b094a5323bca0b67e6404ce05a4e2))
* **accounting:** clearer tax-export window + gate journal export on accounting flag ([3edd832](https://github.com/the-luap/picpeak/commit/3edd8321035c48d6b3e8b157d4b94075657fc7a0))
* **accounting:** data-driven revenue-rate VAT map (multi-country) ([873be91](https://github.com/the-luap/picpeak/commit/873be910a5e88a6d942f116c2bcfecfef9161024))
* **accounting:** move Chart of accounts into Settings → Accounting ([97795f6](https://github.com/the-luap/picpeak/commit/97795f6d1ed25d23396a76b63c225b110ddc315e))
* **accounting:** move Treuhänder export onto the Tax page ([b1f73c1](https://github.com/the-luap/picpeak/commit/b1f73c1df9408ddae821760eb8ed57c726d2e056))
* **accounting:** relocate VAT codes + rate maps into Settings → Accounting ([4ff5b84](https://github.com/the-luap/picpeak/commit/4ff5b84cb66e40be960c2be7a67334cf0cc98be2))
* **accounting:** scope the tax-report export to income-only or cost-only ([9f3b286](https://github.com/the-luap/picpeak/commit/9f3b28684ff36b131420cd975df634a29d660323))
* **accounting:** snapshot the chosen VAT code on quote/invoice create + storno ([5b52969](https://github.com/the-luap/picpeak/commit/5b52969e36a41bbc08a98e0bca1ce0e937d77f56))
* **accounting:** snapshot vat_code on quotes/invoices + export prefers it (foundation) ([0a7dc1c](https://github.com/the-luap/picpeak/commit/0a7dc1cf5da17a5bef204f6680844c8ab2b44269))
* **accounting:** tax report VAT-payable honours registration + reclaim ([d7107aa](https://github.com/the-luap/picpeak/commit/d7107aaf0adf5e03f08085c7691b6530b15ed702))
* **accounting:** unify tax report into one signed, typed, sortable ledger ([fd1dd81](https://github.com/the-luap/picpeak/commit/fd1dd81e8dfc790d960a7e6d888beba895a23461))
* **accounting:** VAT registration + reclaim-country settings in the Accounting tab ([4d87684](https://github.com/the-luap/picpeak/commit/4d876848823bce2c79e629308c92206c64d9893d))
* **accounting:** VAT registration/reclaim settings + un-gated VAT-codes read ([fbbbb8a](https://github.com/the-luap/picpeak/commit/fbbbb8ab7335f07ecf48e217632c7011bd7c88cd))
* **accounting:** VAT-code dropdown in the quote editor (+ reusable VatRateSelect) ([6e1924b](https://github.com/the-luap/picpeak/commit/6e1924bae8b50dbb8460e151c1d9a79553fddb19))
* **branding:** force color mode = standard look; hide overridden theme controls ([4749e22](https://github.com/the-luap/picpeak/commit/4749e222dc41695a2494a3b740952745bb854d4e))
### Bug Fixes
* **accounting:** Banana export is now a tab-separated .txt (actually importable) ([a195067](https://github.com/the-luap/picpeak/commit/a19506749a449ec0a628da776af5a5bea8a2e46e))
* **accounting:** Banana I&E export uses the 'Category' column (not 'ContraAccount') ([53a16f9](https://github.com/the-luap/picpeak/commit/53a16f9f6f9b11c224b3ff5f337f8a7fe4dbfc38))
* **accounting:** emit ISO dates in exports (Postgres returns Date objects) ([0c0fb29](https://github.com/the-luap/picpeak/commit/0c0fb29770d7559b8b35a1b2a0aae1315485d875))
* **accounting:** label the outgoing-invoice totals block in the tax summary ([f3e77e7](https://github.com/the-luap/picpeak/commit/f3e77e78079c6869a3a5de062a90f1a04fecde3c))
* **accounting:** PR [#622](https://github.com/the-luap/picpeak/issues/622) blockers — CSV formula injection + IMAP double-ingest race ([cd6d578](https://github.com/the-luap/picpeak/commit/cd6d57839b4753b2848620c5960332cc945580ce))
* **accounting:** PR [#622](https://github.com/the-luap/picpeak/issues/622) concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap ([a93b6dc](https://github.com/the-luap/picpeak/commit/a93b6dc232375e1362e091c8868a409b1335dcae))
* **accounting:** tax report cost side queried a non-existent column ([ab65a47](https://github.com/the-luap/picpeak/commit/ab65a470a009d33558a7167dd2c3c649f785e686))
* **accounting:** tidy the tax-export scope selector styling ([8deb7e0](https://github.com/the-luap/picpeak/commit/8deb7e0741a5bf559cb9f9b78350821dc84bdb53))
* **accounting:** UTF-8 BOM on the ledger export so Banana reads it correctly ([74144da](https://github.com/the-luap/picpeak/commit/74144da45fc0a7a2c2e88d17a23b584ba77e9262))
* **branding:** force lock = light/dark only; Branding stays the full preset, galleries hide color+mode ([a7c1913](https://github.com/the-luap/picpeak/commit/a7c19135bb9645a7f95d5fe76581098db305394f))
* **branding:** when a force lock is active, collapse the theme customizer to just the Force control ([1ac653a](https://github.com/the-luap/picpeak/commit/1ac653ad1b9f47af8cb24cb53ffa60a1e95192fc))
* **crm:** admin surfaces follow the admin light/dark toggle, not the gallery theme ([#620](https://github.com/the-luap/picpeak/issues/620)) ([d3266a0](https://github.com/the-luap/picpeak/commit/d3266a0d1c458e8ae9c57ccd2f650e699544d2d6))
* **flags:** close CRM/accounting feature-gating gaps from the audit ([03fa3d8](https://github.com/the-luap/picpeak/commit/03fa3d82962d6d6f3cd9630e01258013d567865e))
* **settings:** don't insert non-existent created_at into app_settings ([8621338](https://github.com/the-luap/picpeak/commit/8621338c489cbd5194da6ac22d1fe1bd9d730cb0))
### Documentation
* **readme:** add CRM + accounting to features, tax disclaimer, update contributor ([116743b](https://github.com/the-luap/picpeak/commit/116743ba438505a52b021f80f678c5a0094d20d4))
## [3.61.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.6-beta.0...v3.61.0-beta.0) (2026-06-13)
### Features
* **projects:** Project Overview cockpit — link (multiple) quotes/contracts/hours into projects ([58f93ae](https://github.com/the-luap/picpeak/commit/58f93ae71350cc4a100f15a1a11f478750dace91))
### Bug Fixes
* **projects:** "one customer matches" rule for deal-lineage attach ([f74d8d4](https://github.com/the-luap/picpeak/commit/f74d8d4e8cd9fa040e067ffd751b183a9673161b))
* **projects:** address review — cross-customer guards + email/queue hardening ([9d13880](https://github.com/the-luap/picpeak/commit/9d13880f2b177a1a090c4685798e199a1f47b5ec))
* **projects:** enforce single-customer projects (guard event attach + re-label) ([4b1e85c](https://github.com/the-luap/picpeak/commit/4b1e85c8555b03cbed4abbd80e9cb45b831df6bf))
## [3.60.6-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.5-beta.0...v3.60.6-beta.0) (2026-06-10)
### Bug Fixes
* **gallery:** guest upload honours general_max_files_per_upload + i18n placeholder interpolates ([#613](https://github.com/the-luap/picpeak/issues/613)) ([40a4aa2](https://github.com/the-luap/picpeak/commit/40a4aa2d85d93c9dc1faa69f0e6f8524ff917e29))
* **gallery:** guest upload honours general_max_files_per_upload + i18n placeholder interpolates ([#613](https://github.com/the-luap/picpeak/issues/613)) ([69b5186](https://github.com/the-luap/picpeak/commit/69b5186582d56c42cec520abbe5454171f9f666b))
## [3.60.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.4-beta.0...v3.60.5-beta.0) (2026-06-09)
### Bug Fixes
* **admin/events:** delete cascade orphaned photo folders because it read a non-existent column ([#608](https://github.com/the-luap/picpeak/issues/608)) ([284680e](https://github.com/the-luap/picpeak/commit/284680e0357db20177e45ab4ab01de0fbcac2a98))
* **admin/events:** delete cascade orphaned photo folders because it read a non-existent column ([#608](https://github.com/the-luap/picpeak/issues/608)) ([457c956](https://github.com/the-luap/picpeak/commit/457c9563869156bc4773d873661a75d5115b25db))
## [3.60.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.3-beta.0...v3.60.4-beta.0) (2026-06-08)
### Bug Fixes
* **admin:** graceful logo-img fallback + show sidebar widgets during perm hydration ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up 2) ([f51b9cf](https://github.com/the-luap/picpeak/commit/f51b9cf8df2dfba07590b35cc63def689df98c4a))
* **admin:** logo-img fallback + sidebar perm hydration + filename NFD transliteration ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up 2, [#607](https://github.com/the-luap/picpeak/issues/607)) ([fcd3ca3](https://github.com/the-luap/picpeak/commit/fcd3ca36c659eafa47c036f622da8114433b1c74))
* **downloads:** transliterate accented characters in filename via NFD instead of dropping them ([#607](https://github.com/the-luap/picpeak/issues/607)) ([620163f](https://github.com/the-luap/picpeak/commit/620163f2db77cda40b81edcac79a32cbb4fd278f))
## [3.60.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.2-beta.0...v3.60.3-beta.0) (2026-06-04)
### Bug Fixes
* **security:** re-apply SVG CSP on the direct favicon route (PR [#603](https://github.com/the-luap/picpeak/issues/603) blocker) ([1214b6b](https://github.com/the-luap/picpeak/commit/1214b6b762ce6c763b9a28389c17905d8e47d87f))
## [3.60.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.1-beta.0...v3.60.2-beta.0) (2026-06-04)
### Bug Fixes
* **admin-header:** skeleton brand block + move LanguageSelector into profile menu on &lt;sm ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up) ([b48b5b0](https://github.com/the-luap/picpeak/commit/b48b5b0000fd95bc149335614eb062dd373fc50a))
* **admin-header:** skeleton brand block + move LanguageSelector into profile menu on &lt;sm ([#523](https://github.com/the-luap/picpeak/issues/523) follow-up) ([fe10191](https://github.com/the-luap/picpeak/commit/fe10191b82546473f035435731bf6d6ecca2efd6))
## [3.60.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.60.0-beta.0...v3.60.1-beta.0) (2026-06-02)
### Bug Fixes
* **notifications:** restore /clear-all route the frontend already calls ([#597](https://github.com/the-luap/picpeak/issues/597)) ([940fc60](https://github.com/the-luap/picpeak/commit/940fc607400afa528f7f464c1d41541f46a4070d))
## [3.60.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.59.1-beta.0...v3.60.0-beta.0) (2026-06-02)
### Features
* **restore:** docker-logs visibility + ADMIN_CREDENTIALS.txt restore notice ([3322a1d](https://github.com/the-luap/picpeak/commit/3322a1d998bedf39274dbb473b44e7b1e7cbff51))
### Bug Fixes
* **backup-ui:** respect general_date_format + general_time_format ([09f6a1a](https://github.com/the-luap/picpeak/commit/09f6a1af6acfce5ef0da173fb3c17a16bb679047))
* **restore:** coerce pg bigint counts to Number before comparing (PR [#596](https://github.com/the-luap/picpeak/issues/596) round 2) ([354fbed](https://github.com/the-luap/picpeak/commit/354fbed18220b47167b9bf6b11b3881fc120b7ae))
* **restore:** hoist preservedMeta above SQLite/PG split (PR [#596](https://github.com/the-luap/picpeak/issues/596) blocker) ([a23fa3b](https://github.com/the-luap/picpeak/commit/a23fa3bb12cd05a922c0fb860ae97ffd3fe2baff))
* **restore:** move operator-meta replay after post-restore verification (PR [#596](https://github.com/the-luap/picpeak/issues/596) round 3) ([20e3092](https://github.com/the-luap/picpeak/commit/20e3092c146dd9151b6e7a370f885154f0adeecc))
* **restore:** set was_successful=true on the completed update ([7988c18](https://github.com/the-luap/picpeak/commit/7988c189723fa4639413b3a5339c148521c92c11))
### Documentation
* consolidate disaster-recovery into Backup & Restore guide ([43cb0ea](https://github.com/the-luap/picpeak/commit/43cb0ea4bfc4f241a55d2681f14c064df6b87920))
## [3.59.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.59.0-beta.0...v3.59.1-beta.0) (2026-05-31)
### Bug Fixes
* **admin-header:** hide wordmark on &lt;sm when logo also shows ([#523](https://github.com/the-luap/picpeak/issues/523)) ([c246fd3](https://github.com/the-luap/picpeak/commit/c246fd3cc89962d626218029ab3da1346a726246))
* **admin-header:** truncate long company names on narrow widths ([#523](https://github.com/the-luap/picpeak/issues/523) regression) ([e7cf834](https://github.com/the-luap/picpeak/commit/e7cf834325e8686613fbbee78d52213cb3ba98b1))
* **api/v1/events:** also honour require_password + branding defaults ([#592](https://github.com/the-luap/picpeak/issues/592) follow-up) ([2d44b1a](https://github.com/the-luap/picpeak/commit/2d44b1ab2d251a4fc752cfeb645cb2126c0da3b7))
* **api/v1/events:** honour global devtools-detection default on create ([#592](https://github.com/the-luap/picpeak/issues/592)) ([2304b25](https://github.com/the-luap/picpeak/commit/2304b2562465f8c86c82f2155fbdce264e68fdb3))
* **bug-batch:** [#523](https://github.com/the-luap/picpeak/issues/523) [#564](https://github.com/the-luap/picpeak/issues/564) [#590](https://github.com/the-luap/picpeak/issues/590) [#591](https://github.com/the-luap/picpeak/issues/591) [#592](https://github.com/the-luap/picpeak/issues/592) ([c68a03c](https://github.com/the-luap/picpeak/commit/c68a03c20e70b8049ed851d41e20143813935a3a))
* **csp:** external bootstrap script to survive strict reverse-proxy CSP ([#564](https://github.com/the-luap/picpeak/issues/564)) ([dcc629c](https://github.com/the-luap/picpeak/commit/dcc629cad23ce0ca89aabb6f8b1eacfde599774e))
* **gallery:** preserve per-viewer is_liked across hard refresh ([#590](https://github.com/the-luap/picpeak/issues/590) follow-up) ([791e997](https://github.com/the-luap/picpeak/commit/791e9974eb4c81cc4b095f9b604eccd708fb3a66))
* **gallery:** toggle (not add) the local liked set on click ([#590](https://github.com/the-luap/picpeak/issues/590)) ([d292b9f](https://github.com/the-luap/picpeak/commit/d292b9fa10bffe5d751629b900c606763fa8e73a))
* **nginx:** defensive large_client_header_buffers bump ([#591](https://github.com/the-luap/picpeak/issues/591)) ([c83e883](https://github.com/the-luap/picpeak/commit/c83e88348fbd473fb1de041cebabd3ace65d4d98))
## [3.59.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.58.0-beta.0...v3.59.0-beta.0) (2026-05-29)
### Features
* **admin/users:** reactivate + delete actions for deactivated admin users ([c4a9b36](https://github.com/the-luap/picpeak/commit/c4a9b3636fc7a9209ae724acb7c8cffe141d93ea))
* **admin/users:** reactivate + delete actions for deactivated admin users ([dfcebcc](https://github.com/the-luap/picpeak/commit/dfcebccee98a551980ad2be78a8356fd39894f8a))
## [3.58.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.57.2-beta.0...v3.58.0-beta.0) (2026-05-29)
### Features
* **i18n:** add Slovenian (sl) language support ([433af15](https://github.com/the-luap/picpeak/commit/433af1514687b5c103e72db800954f96fd42b9b8))
## [3.57.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.57.1-beta.0...v3.57.2-beta.0) (2026-05-29)
### Documentation
* list CRM under Beta Features + note dev-compose rebuild gotcha ([1ed4804](https://github.com/the-luap/picpeak/commit/1ed48046cbba1df3710cee197943521aedd1fe3d))
## [3.57.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.57.0-beta.0...v3.57.1-beta.0) (2026-05-29)
### Bug Fixes
* **email:** preserve dots + subaddresses across all normalization sites ([de9a924](https://github.com/the-luap/picpeak/commit/de9a924c77faebc2d0c1ff230a52d8da313259e8))
## [3.57.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.56.0-beta.0...v3.57.0-beta.0) (2026-05-29)
+1 -1
View File
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
## Enforcement
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
+9 -42
View File
@@ -33,12 +33,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
Unsure where to begin? You can start by looking through these issues:
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
### Pull Requests
1. **Fork the repo** and create your branch from `main` (active development)
1. **Fork the repo** and create your branch from `beta`
2. **Install dependencies**:
```bash
cd backend && npm install
@@ -50,10 +50,7 @@ Unsure where to begin? You can start by looking through these issues:
- Linting passes: `npm run lint`
4. **Write tests** if you've added code
5. **Update documentation** if needed
6. **Attach a screenshot for any UI change** (see below)
7. **Create a Pull Request**
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
6. **Create a Pull Request**
## 💻 Development Setup
@@ -82,15 +79,6 @@ cp .env.example .env
docker-compose -f docker-compose.dev.yml up
```
**After pulling changes that touch `backend/package.json` / `backend/package-lock.json` (or the frontend equivalents)**, rebuild the affected image so the live-mounted source can `require()` the new deps:
```bash
docker compose -f docker-compose.dev.yml up -d --build backend
# (or `frontend`, or both)
```
The dev compose bakes `node_modules` into the image while live-mounting `./backend/src` and `./frontend/src` from disk. A dep added on disk won't be picked up until the image is rebuilt — typical symptom is a `MODULE_NOT_FOUND` restart loop on the affected container.
### Running Tests
```bash
@@ -156,37 +144,16 @@ picpeak/
│ └── public/ # Static assets
```
## 🌿 Branch model
PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
## 🔄 Release Process
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
Releases are cut from the `beta` branch (rolling beta) and promoted to `main` (stable) on a 46 week cadence. `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the beta→main merge, hotfix backport path, versioning rules).
## 📮 Contact
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
Thank you for contributing! 🎉
+25 -101
View File
@@ -1,13 +1,5 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
@@ -57,7 +49,6 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
@@ -77,48 +68,27 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
### For Studios — CRM & Accounting (Beta · off by default)
- 📝 **Quotes → Contracts → Invoices** - One deal lineage; cancel-and-reissue (Storno) keeps issued invoices immutable
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
```bash
# Clone the repository
git clone https://github.com/PicPeak/picpeak.git
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
# Copy environment template
cp .env.example .env
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose
docker compose up -d
# Access at http://localhost:3000
```
### First run — create your admin account
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
Note on Docker file permissions
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
@@ -176,8 +146,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Local, S3, rsync destinations
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
@@ -208,7 +177,6 @@ Perfect for:
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
## 🏗️ Tech Stack
@@ -323,12 +291,7 @@ For local development with a receiver on the same machine or docker network, set
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
decodes the full uncompressed frame before resize, and the default two
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
- **RAM**: 2GB minimum
- **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher
@@ -338,26 +301,6 @@ For local development with a receiver on the same machine or docker network, set
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Low-memory hosts
Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
tuning the upload-processor concurrency down. The backend auto-detects
total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
a one-shot warning. You can pin the value explicitly in `.env`:
```env
# Single worker loop — slower batch processing, lower peak RSS
UPLOAD_PROCESSOR_CONCURRENCY=1
```
The trade-off is throughput: a single worker processes one photo at a
time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
note**: if the backend dies under memory pressure, the gallery serves
`503 Service Unavailable` on thumbnails until Docker's
`restart: unless-stopped` brings the container back. Persistent 503s
during/after an upload batch on a low-memory host are almost always this.
### Video Support Requirements
When enabling video uploads, consider these additional resources:
@@ -391,23 +334,17 @@ See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|---------|---------|---------|--------------|----------|
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GBUnlimited*** |
| Client Uploads | ✅ | ✅ | ✅ | Limited |
| API Access | ✅ | Paid | ❌ | ❌ |
| Open Source | ✅ | ❌ | ❌ | ❌ |
| Customer Accounts | ✅ | ❌ | ❌ | ✅ |
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
| Feature | PicPeak | PicDrop | Scrapbook.de |
|---------|---------|---------|--------------|
| Self-Hosted | ✅ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited |
| Monthly Cost | $0 | $29-199 | €19-99 |
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
| Client Uploads | ✅ | ✅ | ✅ |
| API Access | ✅ | Paid | ❌ |
| Open Source | ✅ | ❌ | ❌ |
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
*Limited only by your server storage
## 🛡️ Security
@@ -419,7 +356,7 @@ PicPeak takes security seriously:
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
## 📸 Screenshots
@@ -464,7 +401,6 @@ These features are currently in beta testing and may have limited functionality
| Feature | Description | Status |
|---------|-------------|--------|
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
@@ -503,8 +439,7 @@ PicPeak is inspired by the best features of commercial platforms while remaining
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
@@ -519,11 +454,10 @@ This project was generated with the assistance of AI technology, but has been:
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
## ⚠️ CRM & Accounting disclaimers — examples only, verify locally
## ⚠️ CRM disclaimers — examples only
The CRM & accounting modules (contracts, invoices, QR-bills, the tax
report and the accountant exports) ship seeded content and computed
figures that are intended as a **starting point only**:
The CRM module (contracts, invoices, QR-bills) ships seeded content
that is intended as a **starting point only**:
- **Contract blocks** (image rights, NDA, model release, cancellation,
jurisdiction, …) are written by the maintainer, **not by a lawyer**.
@@ -533,19 +467,9 @@ figures that are intended as a **starting point only**:
typed. Picpeak is open source — please scan a test invoice with your
bank's app to check the QR actually works. We are not responsible for
any mistakes that come from sending an invoice with bad data on it.
- **Tax, VAT & accounting figures** (the tax report, VAT-payable, the
per-rate breakdown, the Treuhänder / Banana export, etc.) are computed
from the data you enter and the defaults you configure. They are
**guidance only and jurisdiction-specific** — tax rules, VAT rates,
deduction schemes (e.g. the Liechtenstein 20 % Gewinnungskosten flat
rate) and filing duties differ by country and change over time. **Every
operator must check their own tax / VAT regulations and verify the
numbers with their accountant / Treuhänder / tax authority before
relying on any figure or export.** Picpeak makes no warranty that the
output is correct for your jurisdiction or situation.
Read [`docs/crm-disclaimers.md`](docs/crm-disclaimers.md) before
enabling the Contracts, Invoices or Accounting features.
enabling the Contracts or Invoices features.
## 📄 License
@@ -565,7 +489,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
</p>
+32 -34
View File
@@ -1,76 +1,74 @@
# Release Process
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs beta) see the [Release Channels section in README.md](README.md#-release-channels).
## TL;DR
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 46 weeks**, or sooner if `main` has been quiet and ready for promotion.
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
- **`beta` branch** receives all merged work. Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` release. Merging that PR tags the beta and publishes Docker images on the `beta` tag.
- **`main` branch** holds the stable channel. Stable releases are cut from a known-good `beta` point via a `release/X.Y.Z-merge-from-beta` branch and a manual PR to `main`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 46 weeks**, or sooner if a beta has been quiet and ready for promotion.
## Cadence target
46 weeks between stable releases is the working target. Reasoning:
- Long enough that each stable carries meaningful changes worth the upgrade burden.
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 46 week window, which gives natural promotion candidates).
- Short enough that beta users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces beta releases (multiple beta points usually accumulate inside a 46 week window, which gives natural promotion candidates).
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
This is a target, not a hard rule. Cut sooner if a beta has been quiet and stable longer than usual. Cut later if a beta is in flux for security or migration reasons.
## Promotion criteria
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
A beta is eligible for promotion to stable when **all** of the following hold:
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
1. **CI green on the candidate beta tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2. **No open `bug`-labelled issues against the candidate beta for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate beta before closing them out.
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on beta before re-evaluating.
## How a stable release is cut
The actual mechanics, in order:
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
1. **Pick the beta tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
2. **Create the release branch from the `main` tip.**
2. **Create the release branch from the beta tip.**
```bash
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
git push origin <beta-tip-sha>:refs/heads/release/X.Y.Z-merge-from-beta
```
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
Naming convention: `release/X.Y.Z-merge-from-beta`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
3. **Open a PR to `main`.** Title: `chore(release): promote beta → main as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example.
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
4. **Resolve conflicts.** Main almost always has commits beta doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take beta's version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on beta are `>=` the pinned versions on main. If main has a newer pinned version (e.g. an emergency CVE backport beta hasn't picked up), take main's pin.
- **`README.md`** — keep main's version if main has had a recent rewrite that beta didn't pick up; otherwise take beta's.
- **`CHANGELOG.md`** — keep main's; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.release-please-manifest.json`** — keep main's; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on beta — beta has already moved on).
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into main's log.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(main): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `stable` and `latest` tags, and creates the GitHub Release page.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
If a critical bug or security issue affects the current stable and beta has moved too far for a full promotion to be appropriate, backport just the fix:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `main`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `stable` with the smallest possible diff.
3. Open a PR to `main` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
5. **Forward-port the fix to beta** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path.
## Versioning
@@ -79,15 +77,15 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
- **Beta suffix** (`-beta.N`) for every beta cut; the `N` counter resets on each new MINOR or MAJOR target.
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
- **Documentation-only changes** can land on either `main` or `beta` directly (no release cut needed); release-please will pick them up on the next regular release.
- **Test-only changes** — same.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting beta won't fix a broken stable-channel workflow until the next promotion.
## When this doc is wrong
+3 -3
View File
@@ -16,7 +16,7 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+3 -16
View File
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
@@ -163,19 +163,6 @@ sudo ./picpeak-setup.sh --native --unattended \
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🔑 First Login — Create Your Admin
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
### Direct Access (Simplest)
@@ -485,7 +472,7 @@ sudo -u picpeak node scripts/reset-admin-password.js
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
@@ -563,4 +550,4 @@ sudo ./picpeak-setup.sh --native \
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
-10
View File
@@ -9,16 +9,6 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
# Generate with: openssl rand -base64 32
#MFA_ENCRYPTION_KEY=
# Auth cookie Secure flag
# unset - default: 'auto' in production, false in dev (#427)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
+5 -32
View File
@@ -7,7 +7,7 @@ ARG VCS_REF
ARG VERSION
# Add labels for GitHub Container Registry
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
@@ -30,14 +30,9 @@ WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -48,21 +43,7 @@ RUN npm install -g npm@11
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
# PDFKit + the web UI use) are registered with fontconfig further down so the
# logo's text renders in its actual typeface, not a fallback.
# poppler-utils provides `pdftoppm`, used to rasterise inbound supplier-invoice
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
# documents (see docs/accounting-inbound-invoices.md).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
fc-cache -f
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -74,14 +55,6 @@ COPY --chown=nodejs:nodejs . .
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
# rasterises an SVG logo its <text> renders in the actual brand typeface
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
# internal family name and recurses into the per-family subdirectories.
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
fc-cache -f /app/assets/fonts
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
@@ -1,242 +0,0 @@
/**
* Integration test for GET /api/admin/system-health/backup-coverage.
*
* Pins the Stage C diagnostic that tells admins what the next
* "Run Backup Now" will include, skip, or silently miss.
*
* Test surface:
* 1. Empty / fresh install → default seed (7 paths), inline mode,
* no DB dump on file yet, no drift
* 2. Toggle `include_in_default=false` → coverage flips to
* 'skipped-by-toggle'
* 3. Feature_flag gating reflects the actual app_settings value
* (events/archived ⇄ backup_include_archived)
* 4. Drift detection: a top-level subdir on disk with no
* `backup_paths` row is flagged in `unconfiguredOnDisk`
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
* 6. Scheduled-only mode + recent dump → `database.ok = true`
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
* and `lastDumpStale = true`
*
* Same auth/permission pass-through strategy as
* adminBackupIntegrity.test.js — we exercise the route's logic,
* not the auth middleware.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
let cleanup;
let storagePath;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkdir(rel) {
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
}
function rmdir(rel) {
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
}
async function restoreDefaultPaths() {
await db('backup_paths').del();
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
}
beforeEach(async () => {
await restoreDefaultPaths();
await db('database_backup_runs').del().catch(() => {});
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
});
it('returns the canonical 7 paths + database block on a fresh install', async () => {
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
const { report } = res.body;
expect(report.paths.map((p) => p.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Default mode is inline — no inline_dump setting present means
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
expect(report.database.mode).toBe('inline');
expect(report.database.ok).toBe(true);
expect(report.summary).toMatchObject({
configuredCount: 7,
tableMissingFallbackInUse: false,
});
});
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
expect(thumbnails.coverage).toBe('skipped-by-toggle');
expect(thumbnails.includeInDefault).toBe(false);
});
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
// backup_include_archived not set → archived skipped via flag
const off = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
expect(archivedOff.featureFlag).toBe('backup_include_archived');
expect(archivedOff.featureFlagValue).toBe(null); // unset
// Now set the flag — but path is missing on disk, so coverage
// resolves to 'missing-on-disk', proving the flag was honoured.
await db('app_settings').insert({
setting_key: 'backup_include_archived',
setting_value: JSON.stringify(true),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const on = await request(app).get('/api/admin/system-health/backup-coverage');
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
expect(archivedOn.featureFlagValue).toBe(true);
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
});
it('detects unconfigured top-level subdirs as drift', async () => {
mkdir('events/active'); // configured
mkdir('plugin-store/cache'); // DRIFT
mkdir('shiny-new-feature/data'); // DRIFT
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
'plugin-store',
'shiny-new-feature',
]));
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
rmdir('plugin-store');
rmdir('shiny-new-feature');
});
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
mkdir('backups');
mkdir('tmp');
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
expect.arrayContaining(['backups', 'tmp']),
);
rmdir('backups');
rmdir('tmp');
});
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
fs.writeFileSync(recentDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(), // just now
status: 'completed',
backup_type: 'pg',
file_path: recentDump,
file_size_bytes: fs.statSync(recentDump).size,
destination_path: recentDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.mode).toBe('scheduled-only');
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
expect(res.body.report.database.lastDumpStale).toBe(false);
expect(res.body.report.database.ok).toBe(true);
});
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
fs.writeFileSync(oldDump, 'pretend old dump');
// 48 hours ago — well past the 26h staleness threshold. ISO
// string instead of a Date object because knex-sqlite's datetime
// serialisation has a quirk where some Date instances coerce to
// '[object Object]' on insert (the test 6 "recent dump" case
// passes only because `new Date()` happens to round-trip safely;
// arithmetic Dates don't).
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
await db('database_backup_runs').insert({
started_at: stale,
completed_at: stale,
status: 'completed',
backup_type: 'pg',
file_path: oldDump,
file_size_bytes: fs.statSync(oldDump).size,
destination_path: oldDump,
});
const res = await request(app).get('/api/admin/system-health/backup-coverage');
expect(res.body.report.database.lastDumpStale).toBe(true);
expect(res.body.report.database.ok).toBe(false);
// Top-level summary reflects the failed DB check.
expect(res.body.report.summary.databaseOk).toBe(false);
expect(res.body.report.summary.overallOk).toBe(false);
});
});
@@ -1,140 +0,0 @@
/**
* Integration test for GET /api/admin/system-health/backup-integrity.
*
* Auth + permission middleware are mocked to pass-through so the test
* focuses on the route's own behaviour: scope-param validation, the
* successResponse envelope, and that the underlying service report
* surfaces correctly in the JSON body.
*
* The verifier service itself is exercised against the real schema
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Pass-through auth so we don't need to mint JWTs.
jest.mock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
customerAuth: (_req, _res, next) => next(),
galleryAuth: (_req, _res, next) => next(),
}));
// Pass-through permissions so settings.view always allows.
jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
let db;
let customerId;
let app;
let storagePath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
storagePath = process.env.STORAGE_PATH;
// Mount the route on a minimal Express app. Cold-require after
// bootCrmDb so the route's downstream `require('../database/db')`
// sees the same db instance.
const route = require('../../src/routes/adminSystemHealth');
app = express();
app.use(express.json());
app.use('/api/admin/system-health', route);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('contracts').del().catch(() => {});
await db('invoices').del().catch(() => {});
await db('quotes').del().catch(() => {});
});
it('returns a report envelope when nothing references any path', async () => {
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('report');
expect(res.body.report.summary).toMatchObject({
totalRows: 0,
missingFiles: 0,
hashMismatches: 0,
verifiedOk: 0,
existsButNoHash: 0,
});
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
it('surfaces a missing file in the response payload', async () => {
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-MISSING',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
created_at: new Date(),
});
const res = await request(app).get('/api/admin/system-health/backup-integrity');
expect(res.status).toBe(200);
expect(res.body.report.summary.missingFiles).toBe(1);
expect(res.body.report.missing[0]).toMatchObject({
table: 'contracts',
column: 'signed_pdf_path',
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
});
});
it('honours the ?scope=invoice filter', async () => {
// Seed both an invoice and a contract with missing files. With
// scope=invoice the contract row must not appear.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'INV-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
due_date: '2026-01-31',
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
created_at: new Date(),
});
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-B7-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
created_at: new Date(),
});
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'invoice' });
expect(res.status).toBe(200);
expect(res.body.report.scopes).toEqual(['invoice']);
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
});
it('rejects an unknown scope with 400 + a code', async () => {
const res = await request(app)
.get('/api/admin/system-health/backup-integrity')
.query({ scope: 'gallery' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
expect(res.body.validScopes).toEqual(expect.arrayContaining([
'quote', 'contract', 'contract-signature', 'invoice',
]));
});
});
@@ -1,88 +0,0 @@
/**
* Regression net for the business-docs coverage gap fixed in this PR.
*
* Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed
* list of storage subdirectories (events/active, events/archived,
* thumbnails, previews, heroes, uploads) and silently omitted the
* entire `business-docs/` tree. That meant every CRM PDF + signature
* drawing — quotes, contracts (system-rendered + wet uploads),
* invoices, Storno, imported historical invoices, and the customer
* signature PNG/JPG drawn on the public signing page — fell outside
* the in-app scheduled backup, leaving every `*_path` column on
* `quotes` / `contracts` / `invoices` as a broken FK after restore.
*
* The fix is a single `scanDirectory(business-docs, ...)` call. This
* suite pins the contract so a future refactor of the walker cannot
* silently drop business-docs again.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
describe('backupService — business-docs is in the backup walker', () => {
let cleanup;
let backupService;
let storagePath;
beforeAll(async () => {
({ cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
// Cold-require after bootCrmDb so backupService picks up the same
// db instance + STORAGE_PATH the test harness configured.
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seed(relPath, content = 'dummy bytes for backup test') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
it('does not error when business-docs is absent', async () => {
// Fresh harness has no business-docs/ tree at all. The walker
// must short-circuit on ENOENT rather than throw — installs that
// never used CRM features have to keep backing up fine.
await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array));
});
it('picks up every CRM-relevant business-docs subdirectory', async () => {
// Seed one file in each of the five subpaths the renderer + import
// routes write to. The signature path is the one most prone to be
// forgotten — it lives one level deeper than the others (per-
// contract subfolder, not per-year).
seed('business-docs/quote/2026/Q-001.pdf');
seed('business-docs/contract/2026/C-001.pdf');
seed('business-docs/contract/signatures/42/customer-1700000000000.png');
seed('business-docs/invoice/2026/INV-001.pdf');
seed('business-docs/invoice-imports/2026/scan.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'business-docs/quote/2026/Q-001.pdf',
'business-docs/contract/2026/C-001.pdf',
'business-docs/contract/signatures/42/customer-1700000000000.png',
'business-docs/invoice/2026/INV-001.pdf',
'business-docs/invoice-imports/2026/scan.pdf',
]));
});
it('walks newly-created business-docs files without needing a restart', async () => {
// The walker reads the filesystem live on every call; this guards
// against a future "cache the scan result at boot" optimisation
// that would miss freshly-written PDFs (which is exactly what
// happens during normal operation — every send writes a new file).
seed('business-docs/invoice/2027/INV-NEW.pdf');
const files = await backupService.getFilesToBackup(false);
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf');
});
});
@@ -1,180 +0,0 @@
/**
* Pins the Stage-B refactor that lifted the file-backup walker's
* subdirectory list out of hard-coded JS into the `backup_paths`
* table seeded by migration 109.
*
* Scenarios:
* 1. Walker reads canonical seed → all 7 default subdirs walked
* 2. include_in_default=false on one row → that subdir is skipped
* 3. New row inserted at runtime → walker picks it up without restart
* 4. feature_flag gating → row only walked when the named app_settings
* boolean is truthy (mirrors historical `includeArchived` behavior)
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
* in depth — never silently scans nothing)
*
* Why not stub `db('backup_paths')`: the whole point of Stage B is
* that the walker is now data-driven, so the test has to actually
* mutate the table and observe the walker's output change. Stubs
* would re-introduce the hard-coding the refactor is meant to remove.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Restore canonical seed before every test. Tests mutate this table
// freely; the next test starts from a known state.
await db('backup_paths').del();
const {
DEFAULT_PATHS,
} = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
});
it('migration 109 seeds the canonical 7 paths', async () => {
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
expect(rows.map((r) => r.path)).toEqual([
'events/active',
'events/archived',
'thumbnails',
'previews',
'heroes',
'uploads',
'business-docs',
]);
// Only events/archived is gated by a feature flag.
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
'events/archived',
]);
});
it('walks every default subdir when files are present', async () => {
seedFile('events/active/E1/a.jpg');
seedFile('thumbnails/E1/a.jpg');
seedFile('previews/E1/a.jpg');
seedFile('heroes/E1/hero.jpg');
seedFile('uploads/intake/x.bin');
seedFile('business-docs/quote/2026/Q-001.pdf');
// events/archived is gated — left out of this test; covered below.
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toEqual(expect.arrayContaining([
'events/active/E1/a.jpg',
'thumbnails/E1/a.jpg',
'previews/E1/a.jpg',
'heroes/E1/hero.jpg',
'uploads/intake/x.bin',
'business-docs/quote/2026/Q-001.pdf',
]));
});
it('skips a path when include_in_default is toggled off', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
await db('backup_paths').where('path', 'thumbnails').update({
include_in_default: false,
});
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('picks up a new path inserted at runtime — no restart needed', async () => {
// Simulates a future feature shipping its own subdirectory and
// self-healing a `backup_paths` row at boot.
await db('backup_paths').insert({
path: 'plugin-store',
include_in_default: true,
feature_flag: null,
display_order: 200,
description: 'Hypothetical future feature payload',
created_at: new Date(),
updated_at: new Date(),
});
seedFile('plugin-store/cache/payload.bin');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('plugin-store/cache/payload.bin');
});
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
seedFile('events/active/E1/active.jpg');
seedFile('events/archived/E2/archived.jpg');
// backup_include_archived=false → archived/ is skipped.
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
const relsOff = filesOff.map((f) => f.relativePath);
expect(relsOff).toContain('events/active/E1/active.jpg');
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
// backup_include_archived=true → archived/ is included.
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
const relsOn = filesOn.map((f) => f.relativePath);
expect(relsOn).toContain('events/archived/E2/archived.jpg');
});
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
// Defense in depth: even if seed-and-self-heal both failed, the
// walker must still cover the historical set so "Run Backup Now"
// cannot silently degrade to no-op.
await db('backup_paths').del();
seedFile('events/active/E1/photo.jpg');
seedFile('business-docs/quote/2026/Q-002.pdf');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
});
it('legacy boolean call signature still works (backward compat)', async () => {
// Existing call sites (and the businessDocs regression test) pass
// a boolean for `includeArchived`. Refactor must not break them.
seedFile('events/archived/E3/legacy.jpg');
const filesOff = await backupService.getFilesToBackup(false);
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
});
@@ -1,188 +0,0 @@
/**
* Pins the inline-DB-dump + fail-loud guard added to `runBackupInternal`.
*
* The previous behaviour was: file-backup looked up an existing dump via
* `getDatabaseBackupInfo()` and silently shipped a files-only manifest
* when none was found. Admins clicking "Run Backup Now" got an apparent
* success that omitted every customer / quote / invoice / contract row —
* the data-loss footgun that this commit closes.
*
* Five scenarios under test:
* 1. Default (inline dump enabled), dump succeeds → backup proceeds
* 2. Default, dump throws → run aborts, backup_runs row marked failed
* 3. Opt-out + recent DB dump available → backup proceeds
* 4. Opt-out + no DB dump available → fail loud
* 5. Opt-out + DB dump file is 0 bytes on disk → fail loud
*
* Mocking strategy: the underlying `databaseBackupService.backup()` and
* the local-destination writer are stubbed so the test exercises just
* the new guard logic without depending on `pg_dump` / `sqlite3` CLI
* binaries being available in the test environment.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Set up mocks BEFORE bootCrmDb so backupService picks them up at require time.
const mockBackupFn = jest.fn();
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: { backup: mockBackupFn },
startScheduledBackups: jest.fn(),
stopScheduledBackups: jest.fn(),
DatabaseBackupService: class {},
}));
jest.setTimeout(30000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let dumpFileAbs;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
// Seed backup destination settings so the run can proceed past the
// "destination not configured" guard.
const dest = path.join(storagePath, 'backups');
fs.mkdirSync(dest, { recursive: true });
// getBackupConfigInternal filters by setting_type='backup', so the
// tests have to seed with that type or the resolver returns
// `{ ... }` with the keys missing — runBackup then sees
// `backup_destination_type === undefined` and bails before our
// new guard runs.
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(dest), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
]).onConflict('setting_key').merge();
// Pre-create a dump file that getDatabaseBackupInfo can resolve to.
// Reused/mutated per-test via the database_backup_runs seed below.
dumpFileAbs = path.join(storagePath, 'backups', 'fake-dump.sql.gz');
fs.writeFileSync(dumpFileAbs, 'pretend this is a pg_dump'.repeat(100));
// Neutralise the file-scan step: we don't care which files would
// be backed up, just whether the run reaches that stage at all.
backupService.getFilesToBackup = jest.fn(async () => []);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockBackupFn.mockReset();
// Default to "dump produced this file with this size" — the per-test
// setup overrides as needed.
mockBackupFn.mockResolvedValue({
success: true,
path: dumpFileAbs,
size: fs.statSync(dumpFileAbs).size,
duration: 1,
checksum: 'abc',
});
// Re-seed the database_backup_runs row that getDatabaseBackupInfo
// resolves against (its query is `status='completed'` + most recent).
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: dumpFileAbs,
file_size_bytes: fs.statSync(dumpFileAbs).size,
destination_path: dumpFileAbs,
});
});
it('default behaviour: inline dump runs, then file backup proceeds', async () => {
// Inline-dump setting is unset (undefined) — default is ON.
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
await backupService.runBackup(true);
expect(mockBackupFn).toHaveBeenCalledTimes(1);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
expect(run.error_message).toBeNull();
});
it('aborts the run when the inline dump throws', async () => {
await db('app_settings').where('setting_key', 'backup_database_inline_dump').del();
mockBackupFn.mockRejectedValueOnce(new Error('pg_dump segfaulted'));
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/pg_dump segfaulted/);
});
it('opt-out: skips inline dump but proceeds when a recent dump exists', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
await backupService.runBackup(true);
expect(mockBackupFn).not.toHaveBeenCalled();
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
});
it('opt-out + no recent dump: fails loud with a clear error', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Wipe the dump row so getDatabaseBackupInfo returns backupFile=null.
await db('database_backup_runs').del();
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/No database backup available/);
});
it('opt-out + 0-byte dump file: fails loud', async () => {
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const emptyDump = path.join(storagePath, 'backups', 'empty-dump.sql.gz');
fs.writeFileSync(emptyDump, '');
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: emptyDump,
file_size_bytes: 0,
destination_path: emptyDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('failed');
expect(run.error_message).toMatch(/is empty/);
});
});
@@ -1,180 +0,0 @@
/**
* Per-Stage-B-path tally — Tier 3 of tonight's backup hardening.
*
* Pins the new `computePerPathStats` logic that the Backup History
* "Content Backed Up" pane reads via `backup_runs.statistics.per_path`.
*
* Three scenarios:
* 1. Single file under one path — straightforward attribution
* 2. Multiple paths with overlapping prefixes — longest-prefix wins
* (e.g. `events/active/E1/x.jpg` should attribute to
* `events/active`, not `events`)
* 3. File outside any configured path — silently dropped, doesn't
* throw or contaminate other buckets
*
* Tests exercise the EXPORTED side: write a backup_runs row via the
* service entry point and assert the statistics JSON shape. We don't
* stub `computePerPathStats` directly — the integration view is what
* the frontend actually consumes.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
let cleanup;
let storagePath;
let backupService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
function mkFile(rel, content = 'x'.repeat(100)) {
const abs = path.join(storagePath, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
beforeEach(async () => {
// Clean slate of any artefacts from prior tests
await db('backup_runs').del();
await db('app_settings').where('setting_type', 'backup').del();
await db('app_settings').insert([
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('local'), setting_type: 'backup' },
{ setting_key: 'backup_destination_path', setting_value: JSON.stringify(path.join(storagePath, 'destination')), setting_type: 'backup' },
{ setting_key: 'backup_enabled', setting_value: JSON.stringify(true), setting_type: 'backup' },
{ setting_key: 'backup_email_on_failure', setting_value: JSON.stringify(false), setting_type: 'backup' },
{ setting_key: 'backup_include_archived', setting_value: JSON.stringify(true), setting_type: 'backup' },
]).onConflict('setting_key').merge();
fs.mkdirSync(path.join(storagePath, 'destination'), { recursive: true });
// Restore canonical backup_paths from migration 109
const { DEFAULT_PATHS } = require('../../migrations/core/109_add_backup_paths');
await db('backup_paths').del();
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})));
// Wipe leftover files between tests
for (const dir of ['events', 'business-docs', 'thumbnails', 'previews', 'heroes', 'uploads']) {
const p = path.join(storagePath, dir);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
});
it('attributes files to their owning backup_paths row', async () => {
mkFile('events/active/E1/photo-a.jpg', 'X'.repeat(1000));
mkFile('events/active/E1/photo-b.jpg', 'X'.repeat(2000));
mkFile('business-docs/quote/2026/Q-1.pdf', 'X'.repeat(500));
mkFile('thumbnails/E1/photo-a.jpg', 'X'.repeat(50));
// Disable the inline DB dump so we don't need pg_dump in tests;
// the file walker is what produces per_path.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
// Seed a fake DB-backup row so the fail-loud guard is satisfied.
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
expect(run.status).toBe('completed');
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
expect(statsRaw.per_path).toBeDefined();
// events/active should have 2 files (3000 bytes)
expect(statsRaw.per_path['events/active']).toEqual({ count: 2, size: 3000 });
// business-docs should have 1 file (500 bytes)
expect(statsRaw.per_path['business-docs']).toEqual({ count: 1, size: 500 });
// thumbnails should have 1 file (50 bytes)
expect(statsRaw.per_path['thumbnails']).toEqual({ count: 1, size: 50 });
// No spurious buckets for paths that had nothing
expect(statsRaw.per_path['previews']).toBeUndefined();
expect(statsRaw.per_path['heroes']).toBeUndefined();
});
it('archived path attributed separately from active when both have files', async () => {
mkFile('events/active/E1/active.jpg', 'X'.repeat(100));
mkFile('events/archived/E2/archived.jpg', 'X'.repeat(200));
// backup_include_archived already set true in beforeEach so the
// archived walker fires; same opt-out for inline DB dump.
await db('app_settings').insert({
setting_key: 'backup_database_inline_dump',
setting_value: JSON.stringify(false),
setting_type: 'backup',
}).onConflict('setting_key').merge();
const fakeDump = path.join(storagePath, 'destination', 'fake.sql.gz');
fs.writeFileSync(fakeDump, 'pretend dump');
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'pg',
file_path: fakeDump,
file_size_bytes: fs.statSync(fakeDump).size,
destination_path: fakeDump,
});
await backupService.runBackup(true);
const run = await db('backup_runs').orderBy('id', 'desc').first();
const statsRaw = typeof run.statistics === 'string'
? JSON.parse(run.statistics)
: run.statistics;
// events/active and events/archived attribute separately —
// longest-prefix match prevents `events/active/...` from claiming
// an `events/archived/...` file or vice versa.
expect(statsRaw.per_path['events/active']).toEqual({ count: 1, size: 100 });
expect(statsRaw.per_path['events/archived']).toEqual({ count: 1, size: 200 });
});
});
// NOTE on walker duplication
//
// If two `backup_paths` rows overlap (e.g. one row at `events` AND
// another at `events/active`), the walker scans the same files twice
// — once via each path. Per-path stats then attribute the file to the
// longest-prefix-matching path BOTH times, producing inflated counts.
//
// The canonical seed in migration 109 contains no overlapping pairs,
// so this isn't exercised in practice. But an admin who hand-adds a
// broad row that overlaps an existing nested one will see double
// counts in their next backup's statistics + the destination will
// receive duplicate copies (wasting space). Worth flagging if anyone
// reports it — the fix is to de-dupe `files` in
// `getFilesToBackupInternal` before returning, OR to skip walking a
// path if a longer one has already covered it.
@@ -1,143 +0,0 @@
/**
* Smoke tests for backupService's config resolution + file-collection
* and manifest validation paths — safety net ahead of the god-file
* decomposition.
*
* Uses the same real-SQLite harness as
* backupService.configurableWalker.test.js (bootCrmDb + a temp
* STORAGE_PATH) rather than the broken deep-mock approach in
* backupService.enhanced.test.js.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let backupManifest;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
backupManifest = require('../../src/services/backupManifest');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('app_settings').del();
// Reset the storage tree so each test starts from a pristine walk.
await fs.promises.rm(storagePath, { recursive: true, force: true });
await fs.promises.mkdir(storagePath, { recursive: true });
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return abs;
}
async function insertBackupSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: value,
setting_type: 'backup',
});
}
describe('getBackupConfig', () => {
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
await insertBackupSetting('backup_enabled', 'true');
await insertBackupSetting('backup_include_archived', 'false');
await insertBackupSetting('backup_retention_days', '30');
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
// Non-backup settings must not leak into the backup config.
await db('app_settings').insert({
setting_key: 'general_site_name',
setting_value: 'PicPeak',
setting_type: 'general',
});
const config = await backupService.getBackupConfig();
expect(config.backup_enabled).toBe(true);
expect(config.backup_include_archived).toBe(false);
expect(config.backup_retention_days).toBe(30);
expect(config.backup_destination_path).toBe('/backups/picpeak');
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
expect(config).not.toHaveProperty('general_site_name');
// Raw (unparsed) values are preserved on the non-enumerable __raw.
expect(String(config.__raw.backup_retention_days)).toBe('30');
});
it('returns an empty config object (not null) when nothing is configured', async () => {
const config = await backupService.getBackupConfig();
expect(config).not.toBeNull();
expect(Object.keys(config)).toHaveLength(0);
});
});
describe('getFilesToBackup', () => {
it('returns an empty list on a pristine storage tree', async () => {
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
expect(files).toEqual([]);
});
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
const content = 'not really a jpeg';
const abs = seedFile('events/active/E9/pic.jpg', content);
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
expect(entry).toBeDefined();
expect(entry.path).toBe(abs);
expect(entry.size).toBe(Buffer.byteLength(content));
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
// realm under Jest and fails the cross-realm instanceof check.
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
});
});
describe('validateBackupManifest', () => {
it('round-trips a generated manifest as valid', async () => {
seedFile('events/active/E1/a.jpg', 'aaa');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const manifest = await backupManifest.generateManifest({
backupType: 'full',
backupPath: '/backup/run-1',
files,
});
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
await backupManifest.saveManifest(manifest, manifestPath, 'json');
const result = await backupService.validateBackupManifest(manifestPath);
expect(result.valid).toBe(true);
expect(result.manifest.backup.type).toBe('full');
expect(result.manifest.files.count).toBe(files.length);
expect(result.manifest.verification.total_checksum).toBeTruthy();
});
it('flags a manifest missing required sections as invalid', async () => {
const badPath = path.join(storagePath, 'manifest-broken.json');
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
const result = await backupService.validateBackupManifest(badPath);
expect(result.valid).toBe(false);
expect(result.error).toMatch(/Missing required section/);
});
});
});
@@ -1,170 +0,0 @@
/**
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
* never auto-sends them before the workflow's review gate + explicit
* send_document.
*/
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
quoteService = require('../../src/services/quoteService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function acceptedQuote() {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
}
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
});
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled');
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
});
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const ev = await db('events').where({ id: res.eventId }).first();
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
// Every invoice the event scheduled is held (no auto-send before the gate).
const invs = await db('invoices').whereIn('id', res.invoiceIds);
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
// again for it (the flow's prepare_invoice adopts these ids instead).
const q = await db('quotes').where({ id: quoteId }).first();
expect(q.converted_event_id).toBe(res.eventId);
});
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
// Reproduces the booking_invoice_only flow on a quote with no explicit
// payment timing: the default installment is after_delivery, which would
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
const dealUuid = crypto.randomUUID();
const [quoteId] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
// 100% after_delivery installment.
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
});
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
const mk = async (lockOffsetMs) => {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF', issue_date: '2026-01-01',
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
responded_at: new Date().toISOString(),
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
accepted_at: new Date().toISOString(),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
};
const openId = await mk(15 * 60 * 1000); // still inside the window
const lockedId = await mk(-60 * 1000); // window already closed
const emitted = await quoteService.finalizeQuoteResponses();
expect(emitted).toBeGreaterThanOrEqual(1);
const open = await db('quotes').where({ id: openId }).first();
const locked = await db('quotes').where({ id: lockedId }).first();
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
const again = await db('quotes').where({ id: lockedId })
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
expect(again).toBe(0);
});
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(res.invoiceIds).toEqual([]);
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
});
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
const quoteId = await acceptedQuote();
const newId = await quoteService.duplicateQuote(quoteId, adminId);
expect(newId).toBeGreaterThanOrEqual(1);
expect(newId).not.toBe(quoteId);
const q = await db('quotes').where({ id: newId }).first();
expect(q.status).toBe('draft');
});
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
expect(typeof registry.getAction(a)).toBe('function');
}
});
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
const contractService = require('../../src/services/contractService');
const quoteId = await acceptedQuote();
const res = await contractService.createFromQuote(quoteId, adminId);
expect(res.contractId).toBeGreaterThanOrEqual(1);
expect(res.alreadyConverted).toBe(false);
const c = await db('contracts').where({ id: res.contractId }).first();
expect(c).toBeTruthy();
});
});
@@ -1,64 +0,0 @@
/**
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
* old slug, so a rename behaves like a rename rather than silently detaching
* existing events/quotes and orphaning the per-type pre-event reminder template.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(30000);
describe('event type slug rename cascade', () => {
let db;
let cleanup;
let customerId;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-points events + quotes + the reminder template from old slug to new', async () => {
// A non-system event type with slug 'party'.
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
// An authored per-type reminder template + an event + a quote, all on 'party'.
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
await db('events').insert({
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
event_name: 'A party', event_date: '2026-09-01',
});
await db('quotes').insert({
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
});
// Rename the slug.
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
// Event + quote follow the rename.
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
// The authored reminder template moved (subject/body preserved), old key gone.
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
expect(moved).toBeTruthy();
expect(moved.subject_en).toBe('Party reminder');
});
it('does not clobber an existing template for the new slug', async () => {
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
// Target already existed → left intact; source not force-merged over it.
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
.toBe('existing soiree');
});
});
@@ -1,231 +0,0 @@
/**
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
*
* Verifies the contract the public route is expected to honour:
* - Browser UA → 302 to target_path
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
* - Unknown slug → 404 Not Found
* - Hit count increments after successful resolutions (both shapes)
*
* Mirrors the production server.js wiring but doesn't load the whole
* server — the surrounding middleware (CORS, helmet, rate limiters)
* isn't part of this route's contract.
*/
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Persist a business_profile + business_name so buildOgMetadata's
// settings-based fields populate consistently.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
service = require('../../src/services/galleryShortUrlService');
const {
isSocialCrawler, buildOgMetadata, renderOgHtml,
} = require('../../src/services/galleryOgService');
app = express();
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await service.findByShortSlug(req.params.shortSlug);
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
if (isSocialCrawler(req.get('user-agent'))) {
const event = await db('events').where({ id: row.event_id }).first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
service.recordHit(row.id).catch(() => {});
return;
}
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
service.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
return res.status(500).type('text/plain').send(err.message);
}
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [eventId] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Test Event',
event_date: '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const row = await service.createShortUrl({
eventId, customSlug: shortSlug,
});
return { eventId, shortUrl: row };
}
// User-agent strings the production `isSocialCrawler` helper matches.
// Snapshot known-true samples here so the test stays in sync if the
// helper's allowlist evolves.
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
it('redirects to the snapshotted target_path with a 302', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'browser-redirect', shortSlug: 'go-here',
});
const res = await request(app)
.get('/s/go-here')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(shortUrl.target_path);
expect(res.headers.location).toMatch(/^\/gallery\//);
});
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
await seedEventAndShortUrl({
slug: 'hit-browser', shortSlug: 'hit-from-browser',
});
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-browser');
expect(row.hit_count).toBe(1);
expect(row.last_hit_at).toBeTruthy();
});
});
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
it('returns 200 with OG HTML for WhatsApp UA', async () => {
await seedEventAndShortUrl({
slug: 'whatsapp-og', shortSlug: 'wa-preview',
});
const res = await request(app)
.get('/s/wa-preview')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.text).toContain('<meta');
expect(res.text).toMatch(/og:title/);
expect(res.text).toMatch(/og:url/);
});
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
await seedEventAndShortUrl({
slug: 'canonical-test', shortSlug: 'canonical-short',
});
const res = await request(app)
.get('/s/canonical-short')
.set('User-Agent', BOT_UA_FACEBOOK);
expect(res.status).toBe(200);
// The og:url meta tag must contain the short-URL path, not the
// /gallery/<slug> path — this is the cache-key invariant from #699.
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
expect(res.text).not.toMatch(
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
);
});
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
await seedEventAndShortUrl({
slug: 'cache-header', shortSlug: 'cache-test',
});
const res = await request(app)
.get('/s/cache-test')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.headers['cache-control']).toMatch(/public/);
expect(res.headers['cache-control']).toMatch(/max-age=300/);
});
it('increments hit_count on a crawler hit as well', async () => {
await seedEventAndShortUrl({
slug: 'hit-bot', shortSlug: 'hit-from-bot',
});
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-bot');
expect(row.hit_count).toBe(1);
});
});
describe('GET /s/:shortSlug — error states', () => {
it('404 for an unknown slug', async () => {
const res = await request(app)
.get('/s/never-existed')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'gone-test', shortSlug: 'gone-slug',
});
await service.softDelete(shortUrl.id, null);
const res = await request(app)
.get('/s/gone-slug')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(410);
});
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
const { eventId } = await seedEventAndShortUrl({
slug: 'orphan-test', shortSlug: 'orphan-slug',
});
// Hard-delete the event row (FK CASCADE would normally clean up the
// short URL too — but if CASCADE didn't fire for whatever reason
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
// resolver should still degrade safely).
// SQLite's foreign_keys pragma is OFF by default; the migration
// doesn't toggle it, so this delete leaves the short URL row.
await db('events').where({ id: eventId }).delete();
const res = await request(app)
.get('/s/orphan-slug')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(410);
});
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
const res = await request(app)
.get('/s/UPPER_CASE')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
});
describe('Regression — existing URL paths must still respond the same', () => {
// The /s/* namespace is additive: it must NOT shadow /gallery/*
// or any of the OG routes. We don't load the whole app here, but we
// can at least pin that the route param doesn't accept slashes —
// i.e. /s/foo/bar must NOT be matched by our handler.
it('the /s/:shortSlug route does not match nested paths', async () => {
const res = await request(app)
.get('/s/foo/bar')
.set('User-Agent', BROWSER_UA);
// Express returns its default 404 when no route matches the path.
expect(res.status).toBe(404);
});
});
@@ -1,282 +0,0 @@
/**
* Integration tests for the branded short-URL service (#699).
*
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
* + recordHit against a real SQLite DB, including the contracts that
* matter for production correctness:
*
* - Custom slug + collision detection (409 with `suggested`)
* - Auto-generated slug from event slug + year
* - Soft-delete preserves the row (admin can audit)
* - target_path snapshots at create time (toggling the global
* "Use short gallery URLs" setting later doesn't change existing
* short URLs — backward-compat invariant from #699)
* - hit_count increments idempotently
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
*
* Boots one DB for the whole file (cheap on SQLite); each test seeds
* its own event row to keep scope clean.
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let adminId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Minimal admin for created_by audit.
const adminInsert = await db('admin_users').insert({
username: 'shorturl-test',
email: 'shorturl@example.com',
password_hash: 'x',
must_change_password: false,
created_at: new Date(),
}).returning('id');
adminId = adminInsert[0]?.id ?? adminInsert[0];
service = require('../../src/services/galleryShortUrlService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
// Each test seeds a fresh event so collisions / counter state don't leak.
async function seedEvent(overrides = {}) {
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [id] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: overrides.event_name || 'Test Wedding',
event_date: overrides.event_date || '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const event = await db('events').where({ id }).first();
return event;
}
describe('createShortUrl — custom slug', () => {
it('creates with a custom slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-1' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'sofia-graduation-1',
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-1');
expect(row.target_path).toBe(`/gallery/${event.slug}`);
expect(row.event_id).toBe(event.id);
expect(row.hit_count).toBe(0);
});
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-2' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'Sofia-GraduAtion-2', // mixed case
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-2');
});
it('rejects an invalid slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'invalid-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'invalid slug with spaces',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a reserved slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'reserved-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'admin',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
const event1 = await seedEvent({ slug: 'dup-test-1' });
const event2 = await seedEvent({ slug: 'dup-test-2' });
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
await expect(service.createShortUrl({
eventId: event2.id, customSlug: 'collide-me',
})).rejects.toMatchObject({
code: 'SLUG_TAKEN',
suggested: expect.any(String),
});
});
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
await expect(service.createShortUrl({
eventId: 9999999, customSlug: 'no-event',
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
});
});
describe('createShortUrl — auto-generated slug', () => {
it('uses event slug + year when no custom slug provided', async () => {
const event = await seedEvent({
slug: 'autogen-wedding', event_date: '2026-06-05',
});
const row = await service.createShortUrl({
eventId: event.id,
createdBy: adminId,
});
// First-choice candidate is just the slug; takes that.
expect(row.short_slug).toBe('autogen-wedding');
});
it('falls back to slug-year when the bare slug is already taken', async () => {
// Both events SHARE the same canonical slug so the first-choice
// bare-slug candidate is burned, forcing autoGen to try the
// year-suffixed variant.
const event1 = await seedEvent({
slug: 'collide-base', event_date: '2026-07-01',
});
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base',
});
const event2 = await seedEvent({
slug: 'collide-base-2', event_date: '2026-07-01',
});
// Force the bare candidate of event2 to also collide by burning it.
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base-2',
});
const row = await service.createShortUrl({
eventId: event2.id, // No custom — auto-gen from event2.slug
});
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
expect(row.short_slug).toBe('collide-base-2-2026');
});
});
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
const event = await seedEvent({ slug: 'snapshot-off' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-off',
});
expect(row.target_path).toBe(`/gallery/${event.slug}`);
});
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
// Persist the setting.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
try {
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-on',
});
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
// CRITICAL backward-compat invariant: now flip the setting OFF.
// Existing short URLs must still resolve to the same target_path
// they were created with — operator's existing share links don't
// silently change behaviour.
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
const refetched = await service.findByShortSlug('snap-on');
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
} finally {
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
}
});
});
describe('findByShortSlug + listForEvent', () => {
it('returns null for an unknown slug', async () => {
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
});
it('returns null for a malformed slug (no DB hit)', async () => {
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
expect(await service.findByShortSlug('with spaces')).toBeNull();
expect(await service.findByShortSlug('')).toBeNull();
});
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
const event = await seedEvent({ slug: 'softdel-find' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'find-deleted',
});
await service.softDelete(created.id, adminId);
const fetched = await service.findByShortSlug('find-deleted');
expect(fetched).not.toBeNull();
expect(fetched.deleted_at).toBeTruthy();
});
it('listForEvent excludes soft-deleted rows', async () => {
const event = await seedEvent({ slug: 'list-test' });
const live = await service.createShortUrl({
eventId: event.id, customSlug: 'list-live',
});
const deleted = await service.createShortUrl({
eventId: event.id, customSlug: 'list-deleted',
});
await service.softDelete(deleted.id, adminId);
const list = await service.listForEvent(event.id);
const ids = list.map((r) => r.id);
expect(ids).toContain(live.id);
expect(ids).not.toContain(deleted.id);
});
});
describe('softDelete', () => {
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
const event = await seedEvent({ slug: 'softdel-idem' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'idem-delete',
});
expect(await service.softDelete(created.id, adminId)).toBe(true);
expect(await service.softDelete(created.id, adminId)).toBe(false);
});
it('returns false for an unknown id (caller maps to 404)', async () => {
expect(await service.softDelete(9999999, adminId)).toBe(false);
});
});
describe('createShortUrl after soft-delete — slug rotation', () => {
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
const event = await seedEvent({ slug: 'rotate' });
const first = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
await service.softDelete(first.id, adminId);
// The slug is now reclaimable for a fresh row.
const second = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
expect(second.id).not.toBe(first.id);
expect(second.short_slug).toBe('rotate-me');
});
});
describe('recordHit', () => {
it('increments hit_count + stamps last_hit_at', async () => {
const event = await seedEvent({ slug: 'hit-counter' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'count-me',
});
await service.recordHit(row.id);
await service.recordHit(row.id);
const fetched = await service.findByShortSlug('count-me');
expect(fetched.hit_count).toBe(2);
expect(fetched.last_hit_at).toBeTruthy();
});
it('is fire-and-forget — invalid id does not throw', async () => {
await expect(service.recordHit(9999999)).resolves.not.toThrow();
});
});
+1 -110
View File
@@ -118,113 +118,4 @@ async function seedMinimal(db) {
return { adminId, customerId };
}
// ---------------------------------------------------------------------
// Route-test helpers (#570) — building blocks for the CRM HTTP layer
// tests. Kept here so every supertest suite shares the same minting +
// app-wiring shape and a refactor lands in one place.
// ---------------------------------------------------------------------
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const express = require('express');
const cookieParser = require('cookie-parser');
/**
* Promote a seeded admin into a role (default `super_admin`) so
* `requirePermission(...)` checks pass. seedMinimal creates an admin
* without a role — that's good for negative tests (expect 403) but
* happy-path tests need the role assignment.
*
* Returns the role id the admin was assigned to.
*/
async function assignAdminRole(db, adminId, roleName = 'super_admin') {
const role = await db('roles').where({ name: roleName }).first();
if (!role) {
throw new Error(`Role '${roleName}' not seeded — check the test DB`);
}
await db('admin_users').where({ id: adminId }).update({ role_id: role.id });
return role.id;
}
/**
* Mint an admin JWT in the same shape adminAuth middleware expects.
* The tests inject this via `Authorization: Bearer <token>`.
*/
function mintAdminToken(adminId, { expiresIn = '1h', extraClaims = {} } = {}) {
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
return jwt.sign(
{ id: adminId, type: 'admin', iat: Math.floor(Date.now() / 1000), ...extraClaims },
process.env.JWT_SECRET,
{ expiresIn, issuer: 'picpeak-auth' }
);
}
/**
* Insert a row into one of the public-token tables for testing the
* loadActionToken guard outcomes. Returns the generated 64-hex token.
*
* Usage:
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: pastDate });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, used_at: new Date() });
* await createPublicToken(db, 'quote_action_tokens', { quote_id: q.id, expires_at: null });
*/
async function createPublicToken(db, tableName, opts = {}) {
const token = opts.token || crypto.randomBytes(32).toString('hex');
const expiresAt = opts.expires_at === null
? null
: (opts.expires_at || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000));
// Serialise Date → ISO string. Bare Date objects round-tripped
// inconsistently through knex+SQLite — sometimes as epoch ms,
// sometimes via .toString() → literal "[object Object]" which then
// parses back to NaN and silently defeats the expiry guard.
const toStorable = (v) => (v instanceof Date ? v.toISOString() : v);
const row = {
...opts,
token,
expires_at: toStorable(expiresAt),
created_at: toStorable(new Date()),
};
await db(tableName).insert(row);
return token;
}
/**
* Build an Express app with the requested route file mounted. Mirrors
* the production app's middleware shape (json + cookies) but skips
* everything else (CORS, helmet, rate limiters) — route tests pin the
* handler's contract, not the surrounding cross-cutting concerns.
*
* Example:
* const app = buildRouteApp('/api/public/quotes',
* require('../../src/routes/publicQuotes'));
*/
function buildRouteApp(mount, router) {
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(mount, router);
// Catch-all error handler. Mirrors the real middleware/errorHandler:
// AppError subclasses (ValidationError, NotFoundError, etc.) use
// `.statusCode` (NOT `.status` — getting that wrong silently maps
// every 400 / 404 / 410 to 500 in tests).
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const statusCode = err.statusCode || err.status || 500;
res.status(statusCode).json({
error: err.message || 'Internal error',
code: err.code,
...(err.details ? { details: err.details } : {}),
});
});
return app;
}
module.exports = {
bootCrmDb,
seedMinimal,
assignAdminRole,
mintAdminToken,
createPublicToken,
buildRouteApp,
};
module.exports = { bootCrmDb, seedMinimal };
@@ -1,211 +0,0 @@
/**
* Incoming-invoice categorisation + re-bill chain (expenseService) against a
* real SQLite schema. Covers the bits unit tests can't: the disposition state
* machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
* the monthly accumulator immediate-bill — i.e. that categorizeInbound /
* billPendingRebills actually mint / amend invoice rows correctly.
*
* No date-range comparisons are exercised here, so it's safe on SQLite (the
* usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
* doesn't apply to this path).
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(60000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
let cleanup;
let adminId;
let expenseService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
// appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
// second write connection deadlocks against the held write lock on
// SQLite. It's fire-and-forget audit noise, irrelevant to these
// assertions, so stub it BEFORE the services destructure it at require
// time. (Production runs Postgres, where the concurrent write is fine.)
const dbModule = require('../../src/database/db');
dbModule.logActivity = async () => {};
({ adminId } = await seedMinimal(db));
expenseService = require('../../src/services/expenseService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
async function captureDoc(overrides = {}) {
const ins = await db('inbound_documents').insert({
source: 'upload',
status: 'unsorted',
parse_status: 'pending',
parse_method: 'none',
supplier_name: 'ACME AG',
currency: 'CHF',
total_amount_minor: 10000,
invoice_date: '2026-06-01',
created_at: new Date(),
updated_at: new Date(),
...overrides,
}).returning('id');
return unwrapId(ins);
}
let customerSeq = 0;
async function makeCustomer(billingCadence) {
customerSeq += 1;
const ins = await db('customer_accounts').insert({
email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
password_hash: 'x',
preferred_language: 'de',
is_active: 1,
billing_cadence: billingCadence || null,
created_at: new Date(),
}).returning('id');
return unwrapId(ins);
}
it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.status).toBe('categorized');
expect(doc.billedInvoiceId).toBeNull();
expect(doc.customerAccountId).toBeNull();
});
it('rebill REQUIRES a customer', async () => {
const id = await captureDoc();
await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
.rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
});
it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc({ total_amount_minor: 10000 });
const doc = await expenseService.categorizeInbound(id, {
disposition: 'rebill', customerAccountId: customerId,
markupType: 'percent', markupPercent: 10,
}, adminId);
expect(doc.disposition).toBe('rebill');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
expect(doc.markupType).toBe('percent');
expect(Number(doc.markupPercent)).toBe(10);
});
it('passthrough never carries a markup, even if one is sent', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
const doc = await expenseService.categorizeInbound(id, {
disposition: 'durchlaufend', customerAccountId: customerId,
markupType: 'percent', markupPercent: 25, // should be ignored
}, adminId);
expect(doc.disposition).toBe('durchlaufend');
expect(doc.customerAccountId).toBe(customerId);
expect(doc.markupType).toBe('none');
expect(doc.markupPercent).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
const customerId = await makeCustomer('monthly');
await expect(expenseService.billPendingRebills(customerId, adminId))
.rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
});
// ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
// customer's pool; monthly-customer immediate-bill onto the running draft)
// both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
// claims its sequence number via the global db, which DEADLOCKS against the
// held write lock on a SQLite-backed harness (a second write connection blocks
// — verified). Production runs Postgres where the concurrent write is fine, so
// this is a harness limitation, not a product bug. The line-amount math is
// covered by the buildInboundLineItem unit tests, and createInvoice itself by
// discountLineItems.test.js. Below we test the UNWIND path against a
// hand-crafted billed state so we don't have to mint through createInvoice. ──
// Build a billed state directly: an invoice with two lines, with the inbound
// doc stamped onto the first line as a prior re-bill.
async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
const invIns = await db('invoices').insert({
invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
customer_account_id: customerId,
status,
scheduled_send_at: scheduledSendAt,
is_monthly_draft: isMonthlyDraft,
currency: 'CHF',
issue_date: '2026-06-01',
due_date: '2026-07-01',
vat_rate: 0,
net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
vat_amount_minor: 0,
total_amount_minor: 7000,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const invoiceId = unwrapId(invIns);
const rebillLineIns = await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
}).returning('id');
const rebillLineId = unwrapId(rebillLineIns);
await db('invoice_line_items').insert({
invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
});
const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
await db('inbound_documents').where({ id }).update({
disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
});
return { id, invoiceId, rebillLineId };
}
it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
const customerId = await makeCustomer('per_event');
const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
expect(recat.disposition).toBe('eigener_aufwand');
expect(recat.billedInvoiceId).toBeNull();
expect(recat.customerAccountId).toBeNull();
// The re-bill line is gone; the sibling line remains and net recomputes.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
const after = await db('invoices').where({ id: invoiceId }).first();
expect(Number(after.net_amount_minor)).toBe(3000);
});
it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
const customerId = await makeCustomer('per_event');
const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
.rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
// Nothing was touched — the line survives.
expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
});
it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
const customerId = await makeCustomer('per_event');
const id = await captureDoc();
// passthrough → pending
let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
expect(doc.customerAccountId).toBe(customerId);
expect(doc.billedInvoiceId).toBeNull();
// → company expense: customer cleared, still no invoice
doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
expect(doc.disposition).toBe('eigener_aufwand');
expect(doc.customerAccountId).toBeNull();
expect(doc.billedInvoiceId).toBeNull();
});
});
@@ -1,214 +0,0 @@
/**
* Install-from-backup boot hook — pins the trigger-file convention.
*
* The hook itself depends on `restoreService.restore`, which is hard
* to fully exercise in an integration test without a real PG cluster
* (sequence resync, DROP/CREATE, etc.). So we stub the actual restore
* and verify the BOOT HOOK logic:
*
* - No trigger file → no-op, ran=false
* - Empty trigger file → picks newest manifest from manifests/
* - Non-empty trigger file → uses the path inside
* - DB not empty → refuses (no restore call)
* - DB not empty + FORCE env → proceeds
* - Successful restore → deletes trigger file
* - Failed restore → leaves trigger file in place
*
* These are the surfaces an admin will hit when actually using the
* feature — the docker-compose-on-real-PG end-to-end test belongs in
* the follow-up CI work captured as task #7 earlier today.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
// Stub the heavy lifting so the test stays fast + portable.
const mockRestore = jest.fn();
jest.mock('../../src/services/restoreService', () => ({
restoreService: {
restore: (...args) => mockRestore(...args),
},
}));
jest.setTimeout(30000);
describe('installFromBackupBoot', () => {
let db;
let cleanup;
let storagePath;
let backupRoot;
let manifestsDir;
let tryInstallFromBackup;
let originalBackupRootEnv;
let originalForceEnv;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupRoot = path.join(storagePath, 'backup');
manifestsDir = path.join(backupRoot, 'manifests');
fs.mkdirSync(manifestsDir, { recursive: true });
originalBackupRootEnv = process.env.BACKUP_ROOT;
originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE;
process.env.BACKUP_ROOT = backupRoot;
({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot'));
}, 120000);
afterAll(async () => {
if (originalBackupRootEnv === undefined) {
delete process.env.BACKUP_ROOT;
} else {
process.env.BACKUP_ROOT = originalBackupRootEnv;
}
if (originalForceEnv === undefined) {
delete process.env.INSTALL_FROM_BACKUP_FORCE;
} else {
process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv;
}
if (cleanup) await cleanup();
});
beforeEach(async () => {
mockRestore.mockReset();
mockRestore.mockResolvedValue({ success: true });
delete process.env.INSTALL_FROM_BACKUP_FORCE;
// Clean trigger files + manifests between tests
for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) {
const p = path.join(backupRoot, name);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
for (const f of fs.readdirSync(manifestsDir)) {
fs.unlinkSync(path.join(manifestsDir, f));
}
// Reset DB to fresh-install state
await db('events').del();
// Leave admin_users alone — fresh-install state has 1 row.
});
it('no trigger file → no-op', async () => {
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(mockRestore).not.toHaveBeenCalled();
});
it('empty trigger file picks the newest manifest from manifests/', async () => {
const older = path.join(manifestsDir, 'backup-manifest-001.json');
const newer = path.join(manifestsDir, 'backup-manifest-002.json');
fs.writeFileSync(older, '{}');
// Set the newer file's mtime slightly later so it wins the sort
const past = new Date(Date.now() - 60_000);
fs.utimesSync(older, past, past);
fs.writeFileSync(newer, '{}');
// Empty trigger
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(newer);
expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
source: 'local',
manifestPath: newer,
restoreType: 'full',
force: true,
skipPreBackup: true,
}));
});
it('non-empty trigger file uses the path inside', async () => {
const specific = path.join(manifestsDir, 'backup-manifest-specific.json');
fs.writeFileSync(specific, '{}');
// Relative to backupRoot
fs.writeFileSync(
path.join(backupRoot, 'RESTORE_ON_INSTALL'),
'manifests/backup-manifest-specific.json\n',
);
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(result.manifestPath).toBe(specific);
});
it('deletes the trigger file after a successful restore', async () => {
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
await tryInstallFromBackup(db);
expect(fs.existsSync(triggerPath)).toBe(false);
});
it('leaves the trigger file in place when restore throws', async () => {
mockRestore.mockRejectedValueOnce(new Error('restore exploded'));
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
fs.writeFileSync(triggerPath, '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/restore exploded/);
expect(fs.existsSync(triggerPath)).toBe(true);
});
it('refuses to run when the install already has events', async () => {
// Simulate an install with existing data
await db('events').insert({
slug: 'existing-event',
event_name: 'Existing Event',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-token',
password_hash: 'dummy-hash-for-test',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(false);
expect(result.error).toMatch(/Database not empty/);
expect(mockRestore).not.toHaveBeenCalled();
// Trigger file should NOT be deleted — admin needs to fix + retry
expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true);
});
it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => {
await db('events').insert({
slug: 'existing-event-2',
event_name: 'Existing Event 2',
event_type: 'wedding',
event_date: new Date(),
host_email: 'host@example.com',
admin_email: 'host@example.com',
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
share_link: 'existing-event-2-token',
password_hash: 'dummy-hash-for-test-2',
created_at: new Date(),
});
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
fs.writeFileSync(manifest, '{}');
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
process.env.INSTALL_FROM_BACKUP_FORCE = 'true';
const result = await tryInstallFromBackup(db);
expect(result.ran).toBe(true);
expect(mockRestore).toHaveBeenCalled();
});
});
@@ -1,115 +0,0 @@
/**
* Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning
* rework. Covers the fee math (flat / percent), the VAT toggle gating
* (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
* accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
* changes the issued invoice total), and the 3-reminder cap.
*
* The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and
* is verified manually; here we assert the data/immutability behaviour.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let invoiceService;
let ids;
async function setSetting(key, value) {
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting(key, JSON.stringify(value), 'crm');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
ids = await seedMinimal(db);
try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
invoiceService = require('../../src/services/invoiceService');
// Stub the (flaky) PDF render so applyReminder exercises its data path.
// eslint-disable-next-line global-require
const pdfService = require('../../src/services/pdfService');
pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
});
afterAll(async () => { await cleanup(); });
describe('dunning fee resolvers', () => {
test('flat fee, no VAT', async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const inv = { total_amount_minor: 100000 };
expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
});
test('percent fee = % of the invoice gross', async () => {
await setSetting('crm_invoices_late_fee_type', 'percent');
await setSetting('crm_invoices_late_fee_percent', 5);
expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
});
test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', true);
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
.toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
// Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
});
});
describe('applyReminder — dunning-document model', () => {
let invoiceId;
let originalTotal;
beforeAll(async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const res = await invoiceService.createInvoice({
customerAccountId: ids.customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
}, ids.adminId);
invoiceId = res.invoiceIds[0];
originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
});
test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(inv.reminder_level).toBe(2);
expect(Number(inv.late_fee_amount_minor)).toBe(2000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
});
test('level 3 accumulates the fee to 2×, total still immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(Number(inv.late_fee_amount_minor)).toBe(4000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal);
});
test('sendReminder refuses to exceed level 3', async () => {
await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow();
});
});
@@ -1,94 +0,0 @@
'use strict';
// Validates the engine-neutral .picpeak export: it must produce a real zip with
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const StreamZip = require('node-stream-zip');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
// bootCrmDb MUST run before requiring the service (which transitively requires
// db.js) so the export reads this test's DB, not the default path.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
({ createPicpeak } = require('../../src/services/picpeakExportService'));
}, 60000);
afterAll(async () => {
await cleanup();
});
async function readZip(filePath) {
const zip = new StreamZip.async({ file: filePath });
const entries = Object.keys(await zip.entries());
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
await zip.close();
return { entries, manifest };
}
describe('picpeak export (.picpeak logical export)', () => {
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
expect(filePath.endsWith('.picpeak')).toBe(true);
expect(fs.existsSync(filePath)).toBe(true);
expect(manifest.format).toBe(1);
expect(manifest.kind).toBe('picpeak-backup');
expect(manifest.database.engine).toBe('sqlite');
expect(manifest.options.includePhotos).toBe(false);
expect(manifest.contains_secrets).toBe(true);
// Migrations seed real tables (e.g. app_settings) — expect several.
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
expect(Object.keys(manifest.tables)).toContain('app_settings');
const { entries, manifest: zipped } = await readZip(filePath);
expect(entries).toContain('manifest.json');
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
expect(entries).toContain('data/app_settings.ndjson');
// Manifest inside the zip matches the returned one.
expect(zipped.tables).toEqual(manifest.tables);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('never exports knex bookkeeping tables', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const names = Object.keys(manifest.tables);
expect(names).not.toContain('knex_migrations');
expect(names).not.toContain('knex_migrations_lock');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('row counts in the manifest match the NDJSON line counts', async () => {
// Insert a couple of settings so at least one table is non-empty.
await db('app_settings')
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
.onConflict('setting_key').merge();
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const zip = new StreamZip.async({ file: filePath });
const buf = await zip.entryData('data/app_settings.ndjson');
await zip.close();
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
});
@@ -1,180 +0,0 @@
'use strict';
// Full .picpeak roundtrip on a temp SQLite DB:
// 1. seed a "backup" instance (admin A + a marker setting)
// 2. export → .picpeak
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
// 4. import the backup with currentAdminId = B
// 5. assert the backup data is restored AND the current account (B) survives,
// while the backup's admin (A) is also present (different email → added).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
let importFromPicpeak;
let validateManifest;
let superAdminRoleId;
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir;
({ createPicpeak } = require('../../src/services/picpeakExportService'));
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 60000);
afterAll(async () => {
await cleanup();
});
const adminRow = (email, hash) => ({
username: email,
email,
password_hash: hash,
role_id: superAdminRoleId,
is_active: true,
must_change_password: false,
created_at: new Date(),
updated_at: new Date(),
});
async function setMarker(value) {
await db('app_settings')
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
.onConflict('setting_key').merge();
}
async function getMarker() {
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
return row ? JSON.parse(row.setting_value) : null;
}
describe('.picpeak roundtrip (export → import)', () => {
it('restores backup data and preserves the current account', async () => {
// 1. Seed the "source" instance.
await db('admin_users').del();
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
await setMarker('from_backup');
// 2. Export.
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// 3. Simulate a reinstall: fresh current admin B, mutated data.
await db('admin_users').del();
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
await setMarker('mutated_after_backup');
// 4. Import, preserving the current admin.
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
expect(result.restored).toBe(true);
expect(result.tables).toBeGreaterThan(0);
// 5a. Backup data restored (marker reverted to the backup value).
expect(await getMarker()).toBe('from_backup');
// 5b. The backup's admin is present (different email → added).
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
expect(a).toBeTruthy();
expect(a.password_hash).toBe('HASH_A');
// 5c. The current account SURVIVES the override, with its own credentials.
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
expect(b).toBeTruthy();
expect(b.password_hash).toBe('HASH_B');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('overwrites a backup admin that collides with the current account email', async () => {
// Source has an admin at the SAME email the current operator will use.
await db('admin_users').del();
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
await setMarker('collision_case');
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// Reinstall: current admin uses the same email but a NEW password.
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
// Exactly one admin at that email, and it keeps the CURRENT password.
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
expect(rows).toHaveLength(1);
expect(rows[0].password_hash).toBe('NEW_HASH');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('restores files/ and reports filesRestored', async () => {
// A business-doc that lives in storage → travels in the backup.
const docDir = path.join(tmpDir, 'business-docs');
const marker = path.join(docDir, 'roundtrip-doc.txt');
fs.mkdirSync(docDir, { recursive: true });
fs.writeFileSync(marker, 'hello');
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
const { filePath } = await createPicpeak({ includePhotos: false });
try {
fs.rmSync(marker); // delete on disk so the restore must bring it back
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
expect(fs.existsSync(marker)).toBe(true);
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
fs.rmSync(docDir, { recursive: true, force: true });
}
});
});
describe('.picpeak manifest validation', () => {
it('rejects a database-engine mismatch', async () => {
// Harness runs on SQLite, so a pg manifest must be refused.
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
});
it('rejects a backup from a newer schema (forward-only)', async () => {
// validateManifest reads knex_migrations for the target's latest migration;
// the harness has none, so create it with an older migration than the backup.
await db.schema.createTable('knex_migrations', (t) => {
t.increments('id');
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
});
try {
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1,
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
tables: {},
});
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
} finally {
await db.schema.dropTableIfExists('knex_migrations');
}
});
it('rejects a file that is not a PicPeak backup', async () => {
const blockers = await validateManifest({ some: 'random-json' });
expect(blockers.length).toBeGreaterThan(0);
});
});
@@ -1,82 +0,0 @@
/**
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
*
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
* the script in a child process (--email <addr> --yes) pointed at the same
* DB file, and asserts the four MFA columns are zeroed. The script runs in
* its own process with its own knex connection; the parent connection is
* idle during the spawn so the SQLite write lock isn't contended.
*/
const path = require('path');
const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
async function seedEnrolledAdmin(email) {
const inserted = await db('admin_users').insert({
username: email.split('@')[0],
email,
password_hash: 'x',
is_active: true,
two_factor_enabled: true,
two_factor_secret: 'iv.tag.ct',
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
two_factor_enrolled_at: new Date(),
created_at: new Date(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
}
it('zeroes the four MFA columns for the targeted admin', async () => {
const email = 'reset-me@example.com';
const id = await seedEnrolledAdmin(email);
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
env: {
...process.env,
NODE_ENV: 'test',
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
},
stdio: 'pipe',
});
const row = await db('admin_users').where({ id }).first();
expect(Number(row.two_factor_enabled)).toBe(0);
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
expect(row.two_factor_enrolled_at).toBeNull();
});
it('leaves a different admin untouched', async () => {
const targetEmail = 'target@example.com';
const bystanderEmail = 'bystander@example.com';
const targetId = await seedEnrolledAdmin(targetEmail);
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
stdio: 'pipe',
});
const target = await db('admin_users').where({ id: targetId }).first();
const bystander = await db('admin_users').where({ id: bystanderId }).first();
expect(Number(target.two_factor_enabled)).toBe(0);
expect(Number(bystander.two_factor_enabled)).toBe(1);
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
});
@@ -1,259 +0,0 @@
/**
* Pins the fix for the PR #596 review blocker.
*
* **The bug**
*
* `preservedMeta` was declared with `let` INSIDE the PostgreSQL
* `else` branch of `performDatabaseRestore`, then read AFTER the
* `else` block closed at the shared replay site (~L1030). On every
* real PG restore:
*
* ReferenceError: preservedMeta is not defined
*
* would fire — psql had already completed the data restore, but
* the operator-meta replay never ran, the trigger file was left
* in place by `_installFromBackupBoot.js` because the restore
* "failed", and `combined.log` got a loud FAILED line even though
* the data was back. Caught on PR #596 review by the maintainer.
*
* **Why CI missed it**
*
* The integration tests around `performFullRestore` only exercise
* the SQLite branch via `this.dbType === 'sqlite'`. The PG branch
* (~L827-984) requires a real PG connection + real `psql` binary,
* neither of which are in the test environment. So the scope leak
* sat untested until the maintainer ran a real DR cycle.
*
* **What this test does**
*
* Reads the source of `restoreService.js` and asserts the scope
* contract: the `preservedMeta` declaration sits ABOVE the
* SQLite/PG branch split, so the replay block at the bottom of the
* try{} can read it on either branch.
*
* Source-inspection is uglier than a runtime test but it has two
* advantages here: (a) it doesn't require a real PG cluster + psql
* binary in CI, (b) it pins the EXACT contract — "the declaration
* must be visible to the replay block" — which is the property
* that broke, more directly than a runtime test would.
*
* The follow-up "real-PG integration test in CI" (separate task)
* would replace this with an end-to-end exercise, at which point
* this can be deleted.
*/
const fs = require('fs');
const path = require('path');
describe('restoreService — PG branch scope contract (PR #596 review)', () => {
let src;
let lines;
beforeAll(() => {
src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'services', 'restoreService.js'),
'utf8',
);
lines = src.split(/\r?\n/);
});
/** Return the 1-based line number of the FIRST line matching `re`. */
function findFirst(re) {
const idx = lines.findIndex((l) => re.test(l));
return idx >= 0 ? idx + 1 : -1;
}
/** Return the 1-based line number of the LAST line matching `re`. */
function findLast(re) {
let last = -1;
lines.forEach((l, i) => { if (re.test(l)) last = i + 1; });
return last;
}
it('preservedMetaSnapshot lives on `this` and is initialised in the constructor', () => {
// PR #596 round 3 moved the snapshot from a block-scoped local to
// an instance variable so the replay can happen in `restore()`
// AFTER post-restore verification — preventing the replay row
// from inflating the row-count check.
//
// Contract:
// 1. The constructor initialises `this.preservedMetaSnapshot = []`
// 2. The `restore()` entry point resets it per call (no leak
// across consecutive runs in the singleton service instance)
// 3. `performDatabaseRestore` assigns to `this.preservedMetaSnapshot`
// inside the PG branch (must run before DROP)
// 4. The replay reads `this.preservedMetaSnapshot` — NOT a bare
// `preservedMeta` local — so a future refactor can't
// accidentally drop the snapshot half on the floor again.
const constructorInit = lines.some((l) =>
/this\.preservedMetaSnapshot\s*=\s*\[\s*\]/.test(l)
);
expect(constructorInit).toBe(true);
const assignmentSites = lines.filter((l) =>
/this\.preservedMetaSnapshot\s*=\s*(\[\s*\]|await\s+db)/.test(l)
);
// Constructor init + restore() per-run reset + the PG-branch
// assignment from db query. Three writes.
expect(assignmentSites.length).toBeGreaterThanOrEqual(3);
// No stray bare `preservedMeta` local-scoped declaration in
// performDatabaseRestore — would indicate someone re-introduced
// the round-1 footgun.
const dangerousLocalDecl = lines.filter((l) =>
/^\s*(let|const)\s+preservedMeta\s*=/.test(l)
);
expect(dangerousLocalDecl).toEqual([]);
});
it('every .count() result is coerced to Number before comparison', () => {
// PR #596 review caught a second PG-only landmine: pg-driver
// returns COUNT(*) as a string ("16" not 16) to preserve bigint
// precision. The original code compared `result.count !==
// expected.rowCount` and every match flagged as a mismatch on PG.
//
// The fix coerces with `Number(...)` at every comparison +
// interpolation site. This test catches a future regression where
// a refactor uses `.count` directly in a `===` / `!==` / `>` /
// `<` comparison without coercing.
//
// Heuristic: find every `.count` access in the file and make sure
// the line either:
// (a) wraps it in `Number(...)`, or
// (b) is purely an interpolation that already coerced upstream
// (e.g. `validation.warnings.push(`... ${eventCountN} ...`)`
// where eventCountN is the coerced local), or
// (c) is the docstring/comment line (filtered separately).
//
// We approximate this by listing every `.count` reference site
// and asserting that lines doing comparisons (`===`/`!==`/`>`/
// `<`/`>=`/`<=`) on a raw `.count` access without `Number(...)`
// around it are zero.
const dangerousLines = lines
.map((l, i) => ({ line: i + 1, text: l }))
// Filter to lines that compare a .count result
.filter(({ text }) => {
// Skip comments
if (/^\s*(\/\/|\*)/.test(text)) return false;
// Detect a `.count` (followed by `)` for `?.count` or by space/operator)
// being directly compared via ===/!==/>/<.
// Match the BAD pattern: `<something>.count <op> <something>`
// where <op> is === / !== / > / < / >= / <=
const bareCountInComparison = /\w+\??\.count\s*(?:!==|===|>=?|<=?)\s+/;
// ALLOW if the .count is preceded by `Number(` in the same line
const wrappedInNumber = /Number\(\s*\w+\??\.count/;
return bareCountInComparison.test(text) && !wrappedInNumber.test(text);
});
expect(dangerousLines).toEqual([]);
});
it('the completed-restore update sets was_successful=true', () => {
// Without this, every successful restore ends up with
// status='completed', was_successful=false — the dashboard's
// "last successful restore" widget then filters out the row +
// any future audit query gating on was_successful misses it.
// Caught locally + maintainer PR #596 review.
//
// Contract: the update payload that writes status='completed' on
// the SUCCESS branch ALSO includes was_successful: true. We pin
// it by source inspection so any future refactor of the success
// payload keeps both fields together.
// The success-branch update lives AFTER performPostRestoreVerification.
// There's also a `status: 'completed'` in the dry-run / early-return
// path (failure handling has its own block too) — we want the
// SUCCESS-branch one specifically.
const verifyLine = findFirst(/performPostRestoreVerification\s*\(/);
expect(verifyLine).toBeGreaterThan(0);
const completedStatusLineIdx = lines
.map((l, i) => ({ line: i + 1, text: l }))
.find(({ line, text }) =>
line > verifyLine && /status:\s*['"]completed['"]/.test(text)
);
expect(completedStatusLineIdx).toBeDefined();
// Look in the next ~10 lines for was_successful: true. The actual
// payload is small (no nested objects between status and the
// closing })), so a fixed-window search is reliable.
const window = lines.slice(
completedStatusLineIdx.line - 1,
completedStatusLineIdx.line + 10,
).join('\n');
expect(window).toMatch(/was_successful:\s*true/);
});
it('npm run migrate:safe is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The
// restore() flow shells out to `npm run migrate:safe` AFTER the
// operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container
// restart).
//
// Contract:
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/['"]migrate:safe['"]/);
expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
expect(replayLine).toBeGreaterThan(0);
expect(migrateLine).toBeGreaterThan(replayLine);
// Must NOT live inside performDatabaseRestore (same scope as the
// replay check above).
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
let dbRestoreEnd = -1;
for (let i = dbRestoreStart; i < lines.length; i++) {
if (/^ \}\s*$/.test(lines[i])) {
dbRestoreEnd = i + 1;
break;
}
}
expect(migrateLine < dbRestoreStart || migrateLine > dbRestoreEnd).toBe(true);
});
it('the replay site lives in restore() AFTER performPostRestoreVerification', () => {
// PR #596 round 3 moved the replay out of performDatabaseRestore
// and into the parent restore() method, sequenced AFTER the
// post-restore verification. Otherwise the replay's upserted row
// count was being flagged as a verification mismatch (e.g.
// "expected 190, got 191" because the fresh-install seeded
// `restore_allow_force_auto_upgraded` that wasn't in the backup).
//
// Contract: the line that drains `this.preservedMetaSnapshot`
// must come AFTER `performPostRestoreVerification` AND must NOT
// sit inside `performDatabaseRestore`.
const verificationLine = findFirst(/performPostRestoreVerification\s*\(/);
expect(verificationLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
expect(replayLine).toBeGreaterThan(0);
expect(replayLine).toBeGreaterThan(verificationLine);
// `performDatabaseRestore` must not contain the replay drain.
// Find the function bounds + assert no drain line falls inside.
const dbRestoreStart = findFirst(/async\s+performDatabaseRestore\s*\(/);
expect(dbRestoreStart).toBeGreaterThan(0);
// Find the closing brace of performDatabaseRestore. Lazy heuristic:
// the first `^ \}\s*$` (two-space indent + }) after the function
// start. Brittle to indent changes but unambiguous in this codebase.
let dbRestoreEnd = -1;
for (let i = dbRestoreStart; i < lines.length; i++) {
if (/^ \}\s*$/.test(lines[i])) {
dbRestoreEnd = i + 1;
break;
}
}
expect(dbRestoreEnd).toBeGreaterThan(dbRestoreStart);
// The replay drain line must be OUTSIDE [dbRestoreStart, dbRestoreEnd].
expect(replayLine < dbRestoreStart || replayLine > dbRestoreEnd).toBe(true);
});
});
@@ -1,196 +0,0 @@
'use strict';
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
// so setupService shares this test's db instance (see crmDb.js note).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let setupService;
let getAppSetting;
let upsertAppSetting;
let app;
const VALID_PW = 'Str0ng-Passw0rd!';
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
// service/util), or db.js binds to the default path instead of the temp one.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 60000);
afterAll(async () => {
await cleanup();
});
beforeEach(async () => {
await db('admin_users').del();
await db('app_settings').where({ setting_key: 'setup_token' }).del();
});
describe('setupService (first-run bootstrap)', () => {
it('reports needsAdmin while no admin exists', async () => {
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('generates and persists a one-time token while no admin exists', async () => {
const token = await setupService.ensureSetupToken();
expect(token).toEqual(expect.any(String));
expect(token.length).toBeGreaterThan(20);
expect(await getAppSetting('setup_token')).toBe(token);
// Idempotent — a second call returns the same token, not a fresh one.
expect(await setupService.ensureSetupToken()).toBe(token);
});
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
// Regression guard for the SQLite-only miss: a bare token string is rejected
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
// value must be JSON-parseable and round-trip back to the token.
const token = await setupService.ensureSetupToken();
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
expect(() => JSON.parse(row.setting_value)).not.toThrow();
expect(JSON.parse(row.setting_value)).toBe(token);
});
it('rejects a wrong token', async () => {
await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 400 });
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('rejects a weak password', async () => {
const token = await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
).rejects.toMatchObject({ statusCode: 400 });
});
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
const token = await setupService.ensureSetupToken();
const result = await setupService.createInitialAdmin({
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
});
expect(result.user.email).toBe('owner@example.com'); // normalised
expect(result.user.role.name).toBe('super_admin');
expect(result.token).toEqual(expect.any(String));
const row = await db('admin_users').first();
const role = await db('roles').where({ name: 'super_admin' }).first();
expect(row.role_id).toBe(role.id);
expect(row.password_hash).not.toBe(VALID_PW); // hashed
// One-time: token burned, status now complete.
expect(await getAppSetting('setup_token')).toBeFalsy();
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
const token = await setupService.ensureSetupToken();
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
});
it('refuses to create a second admin (setup already complete)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
await expect(
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 409 });
});
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
const token = await setupService.ensureSetupToken();
const results = await Promise.allSettled([
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
]);
const fulfilled = results.filter((r) => r.status === 'fulfilled');
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
const count = await db('admin_users').count({ c: '*' }).first();
expect(Number(count.c)).toBe(1);
});
it('ensureSetupToken clears any stale token once an admin exists', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
// Simulate a stale token left in settings, then re-run the boot hook.
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
expect(await setupService.ensureSetupToken()).toBeNull();
expect(await getAppSetting('setup_token')).toBeFalsy();
});
});
describe('setup routes', () => {
it('GET /api/setup/status reports needsAdmin', async () => {
const res = await request(app).get('/api/setup/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsAdmin: true, complete: false });
});
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(200);
expect(res.body).toEqual({ valid: true });
// Token is NOT consumed — it still works for the actual create.
expect(await getAppSetting('setup_token')).toBe(token);
});
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
expect(res.status).toBe(400);
expect(res.body.field).toBe('token');
});
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(409);
});
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
expect(res.status).toBe(400);
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
});
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'owner@example.com', password: VALID_PW });
expect(res.status).toBe(201);
expect(res.body.user.role.name).toBe('super_admin');
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'second@example.com', password: VALID_PW });
expect(res.status).toBe(409);
});
});
@@ -1,661 +0,0 @@
/**
* Workflow engine — graph execution integration tests.
*
* Exercises the engine against a real (temp SQLite) DB with migration 142
* applied: branching, bounded loops, wait pauses + scheduler-style resume,
* gate pauses + confirm/deny resume, dedup idempotency, and step recording.
*/
const { bootCrmDb } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let engine;
async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) {
const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled });
const workflowId = ins[0];
for (const n of nodes) {
await db('workflow_nodes').insert({
workflow_id: workflowId, version: 1, node_key: n.key, type: n.type,
config: JSON.stringify(n.config || {}),
});
}
for (const e of edges) {
await db('workflow_edges').insert({
workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to,
loop_back: e.loopBack || false,
});
}
return workflowId;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Engine requires the singleton db — require AFTER bootCrmDb wired the test path.
engine = require('../../src/services/workflows');
// Enable the workflows flag so emitWorkflowEvent doesn't fail closed.
await db('feature_flags').insert({ key: 'workflows', value: true });
});
afterAll(async () => { await cleanup(); });
describe('workflow engine', () => {
test('condition + bounded loop + wait pauses, resumes to completion', async () => {
// trigger → set paid=false → condition(paid?) --no--> loop(max2)
// loop --loop--> reminder(noop) → wait → (back to condition)
// loop --exit--> lateFee(noop) → end
// condition --yes--> lateFee (paid path, not taken here)
const wfId = await makeWorkflow({
nodes: [
{ key: 'n1', type: 'trigger' },
{ key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } },
{ key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } },
{ key: 'n4', type: 'loop', config: { maxIterations: 2 } },
{ key: 'n5', type: 'action', config: { action: 'noop' } },
{ key: 'n6', type: 'wait', config: { delayMinutes: 0 } },
{ key: 'n7', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'n1', to: 'n2' },
{ from: 'n2', to: 'n3' },
{ from: 'n3', handle: 'no', to: 'n4' },
{ from: 'n3', handle: 'yes', to: 'n7' },
{ from: 'n4', handle: 'loop', to: 'n5' },
{ from: 'n4', handle: 'exit', to: 'n7' },
{ from: 'n5', to: 'n6' },
{ from: 'n6', to: 'n3', loopBack: true },
],
});
const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 });
expect(runIds.length).toBe(1);
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1)
expect(run.current_node).toBe('n6');
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused again (loop iter 2)
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // loop exhausted → exit → end
const ctx = JSON.parse(run.context);
expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap
void wfId;
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.length).toBeGreaterThan(0);
});
test('emit is idempotent on dedup_key', async () => {
await makeWorkflow({
trigger: 'dedup.event',
nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'n1', to: 'n2' }],
});
const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
expect(first.length).toBe(1);
expect(second.length).toBe(0); // same entity → no duplicate run
});
test('gate pauses and resumes via the confirm edge', async () => {
const wfId = await makeWorkflow({
trigger: 'gate.event',
nodes: [
{ key: 'g1', type: 'trigger' },
{ key: 'g2', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
{ key: 'g4', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g1', to: 'g2' },
{ from: 'g2', handle: 'confirm', to: 'g3' },
{ from: 'g2', handle: 'deny', to: 'g4' },
],
});
// create + start a run directly
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending',
context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test',
});
const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first();
await engine.startRun(run0.id);
let run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2');
await engine.resumeRun(run0.id, { decisionHandle: 'confirm' });
run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('runDueWaits resumes only elapsed wait nodes', async () => {
await makeWorkflow({
trigger: 'wait.event',
nodes: [
{ key: 'w1', type: 'trigger' },
{ key: 'w2', type: 'wait', config: { delayMinutes: 60 } },
{ key: 'w3', type: 'action', config: { action: 'noop' } },
],
edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }],
});
const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 });
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
const resumed = await engine.runDueWaits();
expect(resumed).toBeGreaterThanOrEqual(1);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('send_email queues a customer mail with business-hours routing', async () => {
await makeWorkflow({
trigger: 'mail.event',
nodes: [
{ key: 'm1', type: 'trigger' },
{ key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } },
],
edges: [{ from: 'm1', to: 'm2' }],
});
const runIds = await engine.emitWorkflowEvent('mail.event', {
entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' },
});
const run = await db('workflow_runs').where({ id: runIds[0] }).first();
expect(run.status).toBe('done');
const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first();
expect(queued).toBeTruthy();
const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first();
expect(JSON.parse(step.result).respectBusinessHours).toBe(true);
});
test('invoice_paid condition reads the entity', async () => {
const registry = require('../../src/services/workflows/registry');
const cond = registry.getCondition('invoice_paid');
const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) });
expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false);
});
test('gate creates a pending approval + admin email, token confirm resumes the run', async () => {
await makeWorkflow({
trigger: 'approval.event',
nodes: [
{ key: 'a1', type: 'trigger' },
{ key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } },
{ key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path
{ key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path
],
edges: [
{ from: 'a1', to: 'a2' },
{ from: 'a2', handle: 'confirm', to: 'a3' },
{ from: 'a2', handle: 'deny', to: 'a4' },
],
});
const runIds = await engine.emitWorkflowEvent('approval.event', {
entityType: 'invoice', entityId: 42, payload: { adminEmail: 'admin@example.com' },
});
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('a2');
const approval = await db('workflow_approvals').where({ run_id: runId }).first();
expect(approval).toBeTruthy();
expect(approval.status).toBe('pending');
const adminMail = await db('email_queue').where({ recipient_email: 'admin@example.com' }).first();
expect(adminMail).toBeTruthy();
// Extract the raw token from the emailed confirm link and act on it.
const data = JSON.parse(adminMail.email_data);
const rawToken = data.confirm_url.split('/').slice(-2)[0];
const res = await engine.actByToken(rawToken, 'confirm');
expect(res.ok).toBe(true);
expect(res.status).toBe('confirmed');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
// A second click is idempotent (already recorded).
const again = await engine.actByToken(rawToken, 'confirm');
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
expect(all.length).toBe(1);
});
test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
// Simulate an older, never-touched seed (v1, with a legacy gate node).
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
// Admin-owned (admin_toggled_at set) + stale → must NOT be touched.
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) });
const before = await db('workflows').where({ id: wf.id }).first();
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const after = await db('workflows').where({ id: wf.id }).first();
expect(after.version).toBe(before.version); // unchanged
expect(!!after.enabled).toBe(true); // admin's choice preserved
});
test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// First beta: cutover flows ship DISABLED (legacy paths run until enabled);
// they delegate to the proven send functions once turned on.
const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
expect(expiring).toBeTruthy();
expect(!!expiring.enabled).toBe(false);
expect(expiring.trigger_type).toBe('gallery.expiring');
const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
expect(expired).toBeTruthy();
expect(!!expired.enabled).toBe(false);
expect(expired.trigger_type).toBe('gallery.expired');
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
// Invoice-only booking variant (quote → invoice, no gallery).
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
expect(invoiceOnly).toBeTruthy();
expect(!!invoiceOnly.enabled).toBe(false);
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
// Admin review gate guards BOTH document sends (adjust line items, then OK).
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
// dispatch is held until the event date after the admin's early OK.
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
expect(bookingSimple).toBeTruthy();
expect(bookingSimple.trigger_type).toBe('quote.accepted');
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
expect(!!preEvent.enabled).toBe(false); // first beta: ships disabled
expect(preEvent.trigger_type).toBe('event.date_approaching');
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
});
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
const wfId = await makeWorkflow({
trigger: 'event.date_approaching',
enabled: true,
nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'pe1', to: 'pe2' }],
});
// Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' };
await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
const emitted = await engine.emitDueEventReminders();
expect(emitted).toBeGreaterThanOrEqual(1);
const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs.length).toBe(1); // only the in-window event, not the far one
// Idempotent: a second pass dedups (no duplicate run for the same event).
await engine.emitDueEventReminders();
const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs2.length).toBe(1);
});
test('notify_pre_event / sendReminderForEvent sends to an event with a direct email (no CRM account)', async () => {
// Regression: the reminder query used events.customer_account_id, which does
// not exist — so an event with only customer_email/host_email got no mail.
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture,
is_active: true, is_archived: false,
slug: 'rem-direct', share_link: 'rem-direct', event_name: 'Direct',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
customer_email: 'direct@x.test', // event-level email, NOT a customer_account
});
const ev = await db('events').where({ slug: 'rem-direct' }).first();
const res = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ event_id: ev.id }).first();
expect(mail).toBeTruthy();
expect(mail.recipient_email).toBe('direct@x.test');
// Idempotent: sent_at stamped → a second call is a no-op.
const again = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(again.sent).toBe(0);
expect(again.reason).toBe('already_sent');
});
test('reminder template resolves per event type within the chosen group, else group default', async () => {
const { _internal } = require('../../src/services/eventReminderService');
// Per-type template exists within a custom group → used.
await db('email_templates').insert({ template_key: 'promo_wedding' });
expect(await _internal.resolveTemplateKey('wedding', 'promo')).toBe('promo_wedding');
// A type with no authored template (in any group) → the group's default.
expect(await _internal.resolveTemplateKey('zzznotype', 'promo')).toBe('promo_default');
// Blank group → the default event_reminder group.
expect(await _internal.resolveTemplateKey('zzznotype')).toBe('event_reminder_default');
// Trailing underscore on the group is tolerated.
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
});
test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
const { _internal } = require('../../src/services/eventReminderService');
const p = _internal.composePayload({
event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
recipientEmail: 'a@x.test', daysBefore: 2, businessName: 'Biz',
});
expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
expect(p.event_date).not.toMatch(/invalid/i);
});
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
const webhook = engine.registry.getAction('webhook');
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
const ctx = (config, vars = {}) => ({
run: { id: 1, workflow_id: 1, version: 1, trigger_event: 'invoice.sent', entity_type: 'invoice', entity_id: 5 },
node: { config }, vars, db, logger: { warn() {} },
});
// No webhook selected → observable skip, not a crash.
expect(await webhook(ctx({}))).toMatchObject({ skipped: true });
// A configured, active webhook subscription.
const [adminId] = await db('admin_users').insert({ username: 'wfhook', email: 'wf@x.test', password_hash: 'x' });
const [whId] = await db('webhooks').insert({
name: 'Flow hook', url: 'https://example.com/hook', secret: 'whsec_test',
events: JSON.stringify([]), active: true, created_by: adminId,
});
// Dry run does not enqueue.
expect(await webhook(ctx({ webhookId: whId }, { __dryRun: true }))).toMatchObject({ dryRun: true, would: 'webhook' });
expect(await db('webhook_deliveries').where({ webhook_id: whId }).count('id as c').first()).toMatchObject({ c: 0 });
// Real run → a pending delivery is enqueued for the worker (which does the
// signing + SSRF re-validation + retries).
const res = await webhook(ctx({ webhookId: whId }));
expect(res.webhook_enqueued).toBe(whId);
const del = await db('webhook_deliveries').where({ webhook_id: whId }).first();
expect(del).toBeTruthy();
expect(del.status).toBe('pending');
expect(del.event_type).toBe('workflow.invoice.sent');
// Inactive / missing subscription → skip.
await db('webhooks').where({ id: whId }).update({ active: false });
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
});
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
const eventReminderService = require('../../src/services/eventReminderService');
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [custId] = await db('customer_accounts').insert({
email: 'assigned@x.test', preferred_language: 'en', is_active: true, created_at: new Date(),
});
// Event with NO inline customer_email / host_email.
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
});
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
const res = await eventReminderService.sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ recipient_email: 'assigned@x.test' }).first();
expect(mail).toBeTruthy();
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
expect(mail.event_id == null).toBe(true);
});
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// All built-ins ship disabled → inactive until the admin enables one.
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false);
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
// Enable one → now active.
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: true });
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: false }); // restore
});
test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED
// crm_event_reminders_enabled must be on to reach the mutex guard.
await db('app_settings')
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
.onConflict('setting_key').merge();
const eventReminderService = require('../../src/services/eventReminderService');
// Flow disabled → guard does NOT fire (legacy pass owns reminders).
expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(false);
// Flow enabled → the pass stands down before doing any work (byWorkflow).
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: true });
const after = await eventReminderService.runEventReminderPass();
expect(after.byWorkflow).toBe(true);
expect(after.sent).toBe(0);
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: false }); // restore
});
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
// Two enabled flows on the same trigger — the quote picks one.
const chosen = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
const other = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'o1', to: 'o2' }],
});
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
expect(runIds.length).toBe(1);
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
expect(chosenRuns.length).toBe(1); // only the selected flow ran
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
});
test('gate decision with no matching edge FAILS the run (not a silent done)', async () => {
// Gate has a confirm edge but the deny edge was lost (e.g. a bad import).
const wfId = await makeWorkflow({
trigger: 'noedge.event', enabled: true,
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: {} },
{ key: 'g2', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' }, // no deny edge
],
});
const [runId] = await engine.emitWorkflowEvent('noedge.event', { entityType: 'x', entityId: 1 });
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'deny'); // deny has no edge
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('failed'); // loud failure, not a green 'done'
expect(run.error).toMatch(/deny.*no matching edge/i);
});
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
// admin can approve at the gate whenever; the run then parks at the wait and
// the scheduler dispatches when the date arrives.
const wfId = await makeWorkflow({
trigger: 'gatewait.event',
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' },
{ from: 'g2', to: 'g3' },
],
});
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g1'); // parked at the review gate
// Admin confirms EARLY (before the wait date).
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'confirm');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
// Date arrives → scheduler dispatches.
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
await engine.runDueWaits();
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'r1', to: 'r2' }],
});
// Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow).
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2',
context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1',
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first();
const n = await engine.recoverStaleRuns({ staleMs: 1000 });
expect(n).toBeGreaterThanOrEqual(1);
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => {
const wfId = await makeWorkflow({
trigger: 'crashloop.event',
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2',
context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5,
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first();
await engine.recoverStaleRuns({ staleMs: 1000 });
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('failed');
});
test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
const wfId = await makeWorkflow({
trigger: 'testfire.event',
nodes: [
{ key: 't', type: 'trigger' },
{ key: 'w', type: 'wait', config: { delayDays: 14 } },
{ key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
{ key: 'end', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 't', to: 'w' },
{ from: 'w', to: 'g' },
{ from: 'g', handle: 'confirm', to: 'a' },
{ from: 'g', handle: 'deny', to: 'end' },
{ from: 'a', to: 'end' },
],
});
const runId = await engine.testRun(wfId, { dryRun: true });
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
const emailStep = steps.find((s) => s.node_key === 'a');
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
});
});
@@ -1,147 +0,0 @@
/**
* Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals).
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let app;
let token;
let noPermToken;
const sampleGraph = {
name: 'Test flow',
trigger_type: 'invoice.sent',
enabled: false,
nodes: [
{ node_key: 'n1', type: 'trigger' },
{ node_key: 'n2', type: 'action', config: { action: 'noop' } },
],
edges: [{ from_node: 'n1', to_node: 'n2' }],
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
const ins = await db('admin_users').insert({
username: 'norole', email: 'nr@example.com', password_hash: 'x',
must_change_password: false, created_at: new Date(),
}).returning('id');
noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]);
await db('feature_flags').insert({ key: 'workflows', value: true });
app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows'));
});
afterAll(async () => { await cleanup(); });
const auth = (t) => ({ Authorization: `Bearer ${t}` });
describe('admin workflows API', () => {
let createdId;
test('create → 201 with id', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph);
expect(res.status).toBe(201);
expect(res.body.id).toBeGreaterThan(0);
createdId = res.body.id;
});
test('rejects a graph without exactly one trigger', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] });
expect(res.status).toBe(400);
});
test('rejects an unknown node type', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'x', type: 'actoin' }], edges: [] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/unknown node type/i);
});
test('refuses to enable a flow that uses an unregistered action', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false,
nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'totally_not_a_real_action' } }],
edges: [{ from_node: 't', to_node: 'a' }],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/not.*implemented|totally_not_a_real_action/i);
});
test('allows enabling a flow using the now-implemented booking invoice actions', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false,
nodes: [
{ node_key: 't', type: 'trigger' },
{ node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } },
{ node_key: 'g', type: 'gate', config: {} },
{ node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } },
],
edges: [
{ from_node: 't', to_node: 'p' },
{ from_node: 'p', to_node: 'g' },
{ from_node: 'g', from_handle: 'confirm', to_node: 's' },
],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('get one returns the graph', async () => {
const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(res.status).toBe(200);
expect(res.body.nodes).toHaveLength(2);
expect(res.body.edges).toHaveLength(1);
expect(res.body.version).toBe(1);
});
test('list includes it', async () => {
const res = await request(app).get('/api/admin/workflows').set(auth(token));
expect(res.status).toBe(200);
expect(res.body.some((w) => w.id === createdId)).toBe(true);
});
test('update bumps the version', async () => {
const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token))
.send({ ...sampleGraph, name: 'Renamed' });
expect(res.status).toBe(200);
expect(res.body.version).toBe(2);
const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(get.body.name).toBe('Renamed');
expect(get.body.version).toBe(2);
});
test('enable toggle', async () => {
const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('approvals inbox returns an array', async () => {
const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token));
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
test('a role without workflows.manage is forbidden from writing', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph);
expect(res.status).toBe(403);
});
});
@@ -1,78 +0,0 @@
/**
* Regression test for the bulk archive/delete ownership bypass.
*
* bulk-archive and bulk-delete acted on body-supplied event ids with no
* ownership filter, so an admin/editor scoped to their own events (the
* single-event routes enforce requireEventOwnership) could archive or
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
* routes now use to drop foreign/non-existent ids.
*/
// events owned by admin 7; event 3 owned by someone else; event 4 is
// ownerless (legacy). The mock models:
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
const EVENTS = [
{ id: 1, created_by: 7 },
{ id: 2, created_by: 7 },
{ id: 3, created_by: 99 }, // foreign
{ id: 4, created_by: null }, // ownerless/legacy
];
jest.mock('../../src/database/db', () => ({
db: () => {
const q = {
_ids: null,
_adminId: null,
whereIn(_col, ids) { this._ids = ids; return this; },
andWhere(cb) {
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
// by capturing the admin id the callback closes over via a probe.
const probe = {
_adminId: null,
whereNull() { return this; },
orWhere(_col, id) { this._adminId = id; return this; },
};
cb(probe);
this._adminId = probe._adminId;
return this;
},
select() {
return Promise.resolve(
EVENTS
.filter((e) => this._ids.includes(e.id))
.filter((e) => e.created_by === null || e.created_by === this._adminId)
.map((e) => ({ id: e.id }))
);
},
};
return q;
},
}));
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
describe('filterOwnedEventIds', () => {
it('super_admin gets every id, nothing denied', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
);
expect(allowed).toEqual([1, 3, 4, 999]);
expect(denied).toEqual([]);
});
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
);
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
});
it('foreign-only request yields empty allowed', async () => {
const { allowed, denied } = await filterOwnedEventIds(
{ id: 7, roleName: 'editor' }, [3]
);
expect(allowed).toEqual([]);
expect(denied).toEqual([3]);
});
});
@@ -1,103 +0,0 @@
/**
* Regression test for the cross-event thumbnail enumeration leak.
*
* Thumbnails are served flat from /thumbnails/thumb_<name> with
* deterministic, enumerable filenames. photoAuth previously granted any
* holder of a gallery token for ANY active event access to ANY thumbnail
* (it set eventSlug=null and returned next() as long as the token's event
* existed), so a visitor to one gallery could pull another (password-
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
* access to the token's event by matching the requested file against
* photos.thumbnail_path for that event_id.
*/
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
const jwt = require('jsonwebtoken');
// Two events, each owning one thumbnail. The photos mock resolves a row
// only when BOTH event_id and thumbnail_path match — i.e. it models the
// real ownership query.
const EVENTS = [
{ id: 10, slug: 'event-a', is_active: 1 },
{ id: 20, slug: 'event-b', is_active: 1 },
];
const PHOTOS = [
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
];
jest.mock('../../src/database/db', () => ({
db: (table) => ({
_cond: null,
where(cond) { this._cond = cond; return this; },
first() {
if (table === 'events') {
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
}
if (table === 'photos') {
return Promise.resolve(
PHOTOS.find((p) => p.event_id === this._cond.event_id
&& p.thumbnail_path === this._cond.thumbnail_path) || null
);
}
return Promise.resolve(null);
},
}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const photoAuth = require('../../src/middleware/photoAuth');
function galleryToken(eventId) {
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
}
function makeReqRes(token, thumbPath) {
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
return { req, res };
}
describe('photoAuth — thumbnail ownership scoping', () => {
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
// Access denied: middleware must not pass the request through.
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.event).toMatchObject({ id: 20 });
});
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
});
@@ -1,159 +0,0 @@
/**
* HTTP route auth-gate tests for the CRM admin surface (P1 / P2 — #570).
*
* Bundled into one file rather than nine because the contract is the
* same for every CRM admin route:
* - No token → 401 (adminAuth at the router level)
* - Valid token, missing permission → 403 (requirePermission middleware)
* - Valid token + super_admin role → 2xx / 404 (resource-based)
*
* Deeper service-layer behaviour (PDF generation, send, Storno,
* countersign, integrity hash) is covered by the existing service
* unit tests in __tests__/services/. This file pins the contract
* between the HTTP layer and the auth+permission middleware so a
* misconfigured route ("forgot requirePermission") can never ship
* unnoticed.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admincrm-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole,
mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
// One row per admin CRM route. `mount` matches server.js's app.use,
// `loader` is the require()'d router, `getPath` is one path on the
// router we'll exercise. The path should be a GET-shaped read where
// possible — listing endpoints (`/`) are safest because they don't
// require pre-seeded resource ids.
const ROUTES = [
{ name: 'adminQuotes', mount: '/api/admin/quotes', loader: () => require('../../src/routes/adminQuotes'), getPath: '/' },
{ name: 'adminContracts', mount: '/api/admin/contracts', loader: () => require('../../src/routes/adminContracts'), getPath: '/' },
{ name: 'adminInvoices', mount: '/api/admin/invoices', loader: () => require('../../src/routes/adminInvoices'), getPath: '/' },
{ name: 'adminCalendar', mount: '/api/admin/calendar', loader: () => require('../../src/routes/adminCalendar'), getPath: '/items?from=2026-01-01&to=2026-12-31' },
{ name: 'adminDeals', mount: '/api/admin/deals', loader: () => require('../../src/routes/adminDeals'), getPath: '/' },
{ name: 'adminTaxReport', mount: '/api/admin/tax-report', loader: () => require('../../src/routes/adminTaxReport'), getPath: '/?period=2026-Q1' },
{ name: 'adminBusinessProfile', mount: '/api/admin/business-profile', loader: () => require('../../src/routes/adminBusinessProfile'), getPath: '/' },
];
describe('admin CRM routes — auth + permission gate', () => {
let db;
let cleanup;
let adminId;
let customerId;
let superAdminToken;
let invalidToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
// Super-admin: assign the seeded super_admin role (created by
// migration 057). requirePermission lookups short-circuit because
// super_admin role inherits every permission via role_permissions
// rows seeded by mig 107 and earlier.
await assignAdminRole(db, adminId, 'super_admin');
superAdminToken = mintAdminToken(adminId);
// CRM routes have a feature-flag gate that runs INSIDE the route
// handler — even a super-admin gets 403 (`QUOTES_DISABLED` /
// similar) when the flag is off. The flag check is independent
// of permissions, so for happy-path tests we flip every CRM flag
// on. Negative tests (no-token, bad-signature) hit adminAuth
// first and never reach the flag check, so they're unaffected.
// `accounting` is the master flag the tax-report route now requires
// (tax export moved out of CRM into Accounting, independent of bills).
const crmFlags = ['quotes', 'bills', 'contracts', 'hoursLogging', 'calendar', 'taxReport', 'clients', 'accounting'];
for (const key of crmFlags) {
// eslint-disable-next-line no-await-in-loop
await db('feature_flags').where({ key }).update({ value: 1 });
}
// Invalid: signed with a different secret. adminAuth must reject.
const jwt = require('jsonwebtoken');
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe.each(ROUTES)('$name', ({ mount, loader, getPath }) => {
let app;
beforeAll(() => {
app = buildRouteApp(mount, loader());
});
it('returns 401 with no Authorization header', async () => {
const res = await request(app).get(`${mount}${getPath}`);
expect(res.status).toBe(401);
});
it('returns 401 with an invalid JWT signature', async () => {
const res = await request(app)
.get(`${mount}${getPath}`)
.set('Authorization', `Bearer ${invalidToken}`);
expect(res.status).toBe(401);
});
it('returns 2xx (or resource-shaped 4xx) with a valid super-admin token', async () => {
const res = await request(app)
.get(`${mount}${getPath}`)
.set('Authorization', `Bearer ${superAdminToken}`);
// 200 if listing succeeds (likely empty list), 400 if a
// validator complains about query shape, 404 if the route
// doesn't have a list endpoint at `/`. What MUST NOT happen:
// 401 (auth gate failed) or 403 (permission gate failed).
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
describe('adminCustomers — CRM additions (hour-entries / bill / trigger-monthly-bill)', () => {
let app;
beforeAll(() => {
app = buildRouteApp('/api/admin/customers', require('../../src/routes/adminCustomers'));
});
it('GET /:id/hour-entries — 401 without token', async () => {
const res = await request(app).get(`/api/admin/customers/${customerId}/hour-entries`);
expect(res.status).toBe(401);
});
it('GET /:id/hour-entries — 2xx with super-admin token', async () => {
const res = await request(app)
.get(`/api/admin/customers/${customerId}/hour-entries`)
.set('Authorization', `Bearer ${superAdminToken}`);
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
it('POST /:id/hour-entries/bill — 401 without token', async () => {
const res = await request(app)
.post(`/api/admin/customers/${customerId}/hour-entries/bill`)
.send({});
expect(res.status).toBe(401);
});
it('POST /:id/trigger-monthly-bill — 401 without token', async () => {
const res = await request(app)
.post(`/api/admin/customers/${customerId}/trigger-monthly-bill`)
.send({});
expect(res.status).toBe(401);
});
});
});
@@ -1,200 +0,0 @@
/**
* HTTP smoke tests for the core admin event CRUD endpoints:
* POST /api/admin/events (create)
* GET /api/admin/events (list + pagination)
* GET /api/admin/events/:id (detail + stats)
* PUT /api/admin/events/:id (update)
* DELETE /api/admin/events/:id (cascade delete)
*
* Safety net ahead of the adminEvents.js god-file decomposition —
* pins the request/response contracts of the main CRUD paths using
* the same real-SQLite harness as slideshowAdmin.test.js.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-smoke-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('admin events CRUD endpoints (smoke)', () => {
let db; let cleanup; let app; let adminId; let token;
// bootCrmDb's full migration run intermittently exceeds Jest's default
// 5s beforeAll timeout on slower CI runners; raise it.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('email_queue').del();
await db('events').del();
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
it('401s without an admin token', async () => {
const res = await request(app).get('/api/admin/events');
expect(res.status).toBe(401);
});
describe('POST /', () => {
it('creates an event, mints slug + share link and persists the row', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'wedding',
event_name: 'Smoke Wedding',
event_date: '2026-09-01',
// Field requirements default to ON (getEventFieldRequirements)
// so customer + admin contact data must be supplied.
customer_name: 'Client Person',
customer_email: 'client@example.com',
admin_email: 'admin@example.com',
require_password: false,
is_draft: true,
});
expect(res.status).toBe(200);
expect(res.body.id).toBeDefined();
expect(res.body.slug).toContain('wedding-smoke-wedding');
expect(typeof res.body.share_link).toBe('string');
expect(res.body.is_draft).toBe(true);
const row = await db('events').where({ id: res.body.id }).first();
expect(row).toBeDefined();
expect(row.event_name).toBe('Smoke Wedding');
expect(row.created_by).toBe(adminId);
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
// Draft creates must NOT queue the gallery_created email.
const queued = await db('email_queue').where({ event_id: res.body.id });
expect(queued).toHaveLength(0);
});
it('400s on an invalid event type', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'not-a-real-type',
event_name: 'Broken',
require_password: false,
});
expect(res.status).toBe(400);
expect(Array.isArray(res.body.errors)).toBe(true);
});
});
describe('GET /', () => {
it('lists events with pagination metadata and photo counts', async () => {
await insertEvent(db, adminId, { event_name: 'Alpha' });
await insertEvent(db, adminId, { event_name: 'Beta' });
const res = await auth(request(app).get('/api/admin/events'));
expect(res.status).toBe(200);
expect(res.body.events).toHaveLength(2);
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
for (const ev of res.body.events) {
expect(ev.photo_count).toBe(0);
}
});
});
describe('GET /:id', () => {
it('returns the event with photo/view stats', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
const res = await auth(request(app).get(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.event_name).toBe('Detail Event');
expect(res.body.photo_count).toBe(0);
expect(res.body.total_views).toBe(0);
expect(res.body.total_downloads).toBe(0);
expect(Array.isArray(res.body.recent_photos)).toBe(true);
});
it('404s for an unknown event id', async () => {
const res = await auth(request(app).get('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
describe('PUT /:id', () => {
it('updates mutable fields and persists them', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Before' });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
event_name: 'After',
welcome_message: 'Hello guests',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('After');
expect(row.welcome_message).toBe('Hello guests');
});
it('404s when updating a missing event', async () => {
const res = await auth(request(app).put('/api/admin/events/999999')).send({
event_name: 'Ghost',
});
expect(res.status).toBe(404);
});
});
describe('DELETE /:id', () => {
it('cascade-deletes the event row', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.message).toMatch(/deleted/i);
const row = await db('events').where({ id }).first();
expect(row).toBeUndefined();
});
it('404s when deleting a missing event', async () => {
const res = await auth(request(app).delete('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
});
-345
View File
@@ -1,345 +0,0 @@
/**
* HTTP-level tests for the admin TOTP MFA feature (#738).
*
* Two surfaces:
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
* /api/admin/auth (src/routes/adminAuth.js).
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
* (src/routes/auth.js, mounted /api/auth).
*
* Uses the same real-SQLite harness as the CRM route tests
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
* generated in-test via otplib's authenticator against the secret the
* /setup endpoint returns in plaintext.
*
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
* first require of db.js — mirror adminCrmAuth.test.js exactly.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
// tests don't need a token. Be explicit so a leaked env can't flip it on.
delete process.env.RECAPTCHA_SECRET_KEY;
const request = require('supertest');
const bcrypt = require('bcrypt');
const { authenticator } = require('otplib');
const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
jest.setTimeout(60000);
let db;
let cleanup;
let adminApp; // /api/admin/auth (enrollment)
let authApp; // /api/auth (login challenge)
/**
* Seed a bare admin (password known) and return its id + login creds.
* seedMinimal always creates username 'tester'; we need distinct rows per
* scenario, so insert directly with a unique username/email.
*/
async function seedAdmin({ username, superAdmin = false } = {}) {
const password = 'correct-horse';
const passwordHash = await bcrypt.hash(password, 4);
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
const row = {
username: uname,
email: `${uname}@example.com`,
password_hash: passwordHash,
must_change_password: false,
is_active: true,
created_at: new Date(),
};
if (superAdmin) {
const role = await db('roles').where({ name: 'super_admin' }).first();
if (!role) throw new Error('super_admin role not seeded');
row.role_id = role.id;
}
const inserted = await db('admin_users').insert(row).returning('id');
const id = inserted[0]?.id ?? inserted[0];
return { id, username: uname, password };
}
/** Run the full setup→enable enrollment against the live app. Returns
* the plaintext TOTP secret (for later login codes) and recovery codes. */
async function enroll(adminId) {
const token = mintAdminToken(adminId);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(setup.status).toBe(200);
const secret = setup.body.secret;
const enable = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(enable.status).toBe(200);
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.secret).toEqual(expect.any(String));
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
// Not yet enabled: status must still report disabled.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
// And the row stores an encrypted secret (not the plaintext one).
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeTruthy();
expect(row.two_factor_secret).not.toBe(res.body.secret);
expect(Number(row.two_factor_enabled)).toBe(0);
});
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
const admin = await seedAdmin();
const { recoveryCodes, token } = await enroll(admin.id);
expect(Array.isArray(recoveryCodes)).toBe(true);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.status).toBe(200);
expect(status.body.enabled).toBe(true);
expect(status.body.recoveryCodesRemaining).toBe(10);
expect(status.body.enrolledAt).toBeTruthy();
});
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const setup = await request(adminApp)
.post('/api/admin/auth/mfa/setup')
.set('Authorization', `Bearer ${token}`);
const valid = authenticator.generate(setup.body.secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: wrong });
expect(res.status).toBe(400);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
});
it('enable before setup is rejected', async () => {
const admin = await seedAdmin();
const token = mintAdminToken(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/enable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '123456' });
// No provisional secret → ValidationError (400).
expect(res.status).toBe(400);
});
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
expect(noToken.status).toBe(401);
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
expect(setup.status).toBe(401);
});
// Regression guard for #735: super_admin used to be blocked from enrolling.
// Enrollment operates on req.admin.id and is role-agnostic — assert a
// super_admin can complete the full setup→enable flow.
it('#735 regression — a super_admin can enroll in MFA', async () => {
const admin = await seedAdmin({ superAdmin: true });
const { recoveryCodes, token } = await enroll(admin.id);
expect(recoveryCodes).toHaveLength(10);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(true);
});
});
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
it('requires a valid code; a wrong code is rejected and state persists', async () => {
const admin = await seedAdmin();
const { token } = await enroll(admin.id);
const bad = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: '000000' });
expect(bad.status).toBe(400);
const stillOn = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(stillOn.body.enabled).toBe(true);
});
it('a valid TOTP disables MFA and clears the stored secret', async () => {
const admin = await seedAdmin();
const { secret, token } = await enroll(admin.id);
const res = await request(adminApp)
.post('/api/admin/auth/mfa/disable')
.set('Authorization', `Bearer ${token}`)
.send({ code: authenticator.generate(secret) });
expect(res.status).toBe(200);
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${token}`);
expect(status.body.enabled).toBe(false);
expect(status.body.recoveryCodesRemaining).toBe(0);
const row = await db('admin_users').where({ id: admin.id }).first();
expect(row.two_factor_secret).toBeNull();
expect(row.two_factor_recovery_codes).toBeNull();
});
});
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBe(true);
expect(res.body.mfaToken).toEqual(expect.any(String));
expect(res.body.user).toBeUndefined(); // no completed session
// No admin auth cookie should have been set on the challenge response.
const cookies = res.headers['set-cookie'] || [];
expect(cookies.join(';')).not.toMatch(/adminToken/i);
});
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
const admin = await seedAdmin();
const res = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
expect(res.status).toBe(200);
expect(res.body.mfaRequired).toBeUndefined();
expect(res.body.user).toBeDefined();
expect(res.body.user.username).toBe(admin.username);
});
it('login/mfa with a valid TOTP completes the session', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const { mfaToken } = challenge.body;
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken, code: authenticator.generate(secret) });
expect(res.status).toBe(200);
expect(res.body.user).toBeDefined();
expect(res.body.user.id).toBe(admin.id);
});
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
const admin = await seedAdmin();
const { secret } = await enroll(admin.id);
const challenge = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const valid = authenticator.generate(secret);
const wrong = valid === '000000' ? '111111' : '000000';
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
expect(res.status).toBe(401);
expect(res.body.code).toBe('MFA_INVALID');
expect(res.body.user).toBeUndefined();
});
it('a recovery code logs in and is then single-use (second use fails)', async () => {
const admin = await seedAdmin();
const { recoveryCodes } = await enroll(admin.id);
const recovery = recoveryCodes[0];
// First challenge + recovery-code exchange succeeds.
const c1 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const first = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c1.body.mfaToken, code: recovery });
expect(first.status).toBe(200);
expect(first.body.user).toBeDefined();
// recoveryCodesRemaining dropped by one.
const status = await request(adminApp)
.get('/api/admin/auth/mfa/status')
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
expect(status.body.recoveryCodesRemaining).toBe(9);
// Second use of the SAME recovery code must fail.
const c2 = await request(authApp)
.post('/api/auth/admin/login')
.send({ username: admin.username, password: admin.password });
const second = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: c2.body.mfaToken, code: recovery });
expect(second.status).toBe(401);
expect(second.body.code).toBe('MFA_INVALID');
});
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
const admin = await seedAdmin();
await enroll(admin.id);
const res = await request(authApp)
.post('/api/auth/admin/login/mfa')
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
expect(res.status).toBe(401);
});
});
@@ -1,153 +0,0 @@
/**
* HTTP route tests for backend/src/routes/publicContracts (P0 — #570).
*
* Four endpoints on the customer-facing surface:
* GET /:token — load contract for signing
* POST /:token/sign — in-browser canvas signature submission
* POST /:token/upload-signed-pdf — wet-signed PDF upload
* GET /:token/pdf — download the contract PDF
*
* Tests pin the publicTokenGuards.loadActionToken contract per
* endpoint and a few endpoint-specific shape assertions. Deeper
* service-layer behaviour (PDF generation, signature attachment,
* integrity-hash compute) is covered by the contractService unit
* tests; here we only assert the HTTP contract.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubcontracts-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
describe('publicContracts routes', () => {
let db;
let cleanup;
let app;
let customerId;
let contractId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('contracts').insert({
contract_number: 'K-TEST-0001',
customer_account_id: customerId,
title: 'Test Booking Confirmation',
issue_date: new Date().toISOString().slice(0, 10),
status: 'sent',
language: 'de',
created_at: new Date().toISOString(),
}).returning('id');
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) tokenGuards._internal.badAttempts.clear();
});
describe('GET /:token', () => {
it('returns 404 for an unknown well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}`);
expect(res.status).toBe(404);
});
it('rejects malformed tokens with 400 before reaching the guard', async () => {
const res = await request(app).get('/api/public/contracts/short');
expect(res.status).toBe(400);
});
it('returns 410 for an expired token', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
it('returns 200 with the contract payload for a valid token', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app).get(`/api/public/contracts/${token}`);
expect(res.status).toBe(200);
expect(res.body.contract).toBeDefined();
});
});
describe('POST /:token/sign', () => {
it('rejects missing required fields (name, accepted) with 400', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app)
.post(`/api/public/contracts/${token}/sign`)
.send({}); // missing name + accepted
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on sign', async () => {
const fakeToken = 'b'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/sign`)
.send({ name: 'Jane Doe', accepted: true });
// Either 404 (token not found) or service-level error mapped to
// 4xx — what matters is the request didn't slip past validation.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
});
describe('POST /:token/upload-signed-pdf', () => {
it('rejects malformed tokens with 400 before multer runs', async () => {
const res = await request(app)
.post('/api/public/contracts/bad-token/upload-signed-pdf')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(400);
});
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/contracts/${fakeToken}/upload-signed-pdf`)
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(404);
});
});
describe('GET /:token/pdf', () => {
it('returns 404 for an unknown token on PDF download', async () => {
const fakeToken = 'd'.repeat(64);
const res = await request(app).get(`/api/public/contracts/${fakeToken}/pdf`);
expect(res.status).toBe(404);
});
it('returns 410 for an expired token on PDF download', async () => {
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId, expires_at: past,
});
const res = await request(app).get(`/api/public/contracts/${token}/pdf`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
});
});
@@ -1,106 +0,0 @@
/**
* HTTP route tests for backend/src/routes/publicPaymentCheck (P0 — #570).
*
* Two endpoints:
* GET /:token — load invoice payment-check view
* POST /:token — record customer's "paid / unpaid / partial" claim
*
* Unlike the quote / contract public routes, payment-check goes
* through invoiceService rather than the shared publicTokenGuards.
* Tests focus on the validator gates and the unknown-token edge.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paymentcheck-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
describe('publicPaymentCheck routes', () => {
let cleanup;
let app;
beforeAll(async () => {
let db;
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('GET /:token', () => {
it('rejects malformed tokens with 400', async () => {
const res = await request(app).get('/api/public/payment-check/short');
expect(res.status).toBe(400);
});
it('returns a service-level error for an unknown well-formed token (4xx, not 500)', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/payment-check/${fakeToken}`);
// Service throws NotFound or similar — what matters is the
// request reaches the service AND isn't an unhandled 500.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(600);
});
});
describe('POST /:token', () => {
it('rejects malformed tokens with 400', async () => {
const res = await request(app)
.post('/api/public/payment-check/short')
.send({ action: 'paid_full' });
expect(res.status).toBe(400);
});
it('rejects an invalid action with 400', async () => {
const validToken = 'b'.repeat(64);
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action: 'maybe' });
expect(res.status).toBe(400);
});
it('accepts the canonical four actions through the validator', async () => {
// Each action passes validator (token is well-formed); service
// then rejects unknown token with a 4xx — what we're pinning is
// the validator doesn't reject any of the canonical actions.
const validToken = 'c'.repeat(64);
for (const action of ['paid_full', 'paid_with_skonto', 'partial', 'unpaid']) {
// eslint-disable-next-line no-await-in-loop
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action });
// Either succeeds (rare — no real invoice) or service-level
// 4xx for unknown token. Must NOT be 400 (which would mean
// the validator rejected the action).
expect(res.status).not.toBe(400);
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(600);
}
});
it('rejects negative amountMinor with 400', async () => {
// Validator chain: optional({ values: 'falsy' }) means
// amountMinor=0 / null / undefined gets skipped (allowed). For
// any actually-supplied integer, isInt({ min: 1 }) takes over —
// pin the negative-rejection so a future refactor can't loosen
// the lower bound silently.
const validToken = 'd'.repeat(64);
const res = await request(app)
.post(`/api/public/payment-check/${validToken}`)
.send({ action: 'partial', amountMinor: -100 });
expect(res.status).toBe(400);
});
});
});
@@ -1,171 +0,0 @@
/**
* HTTP route tests for backend/src/routes/publicQuotes (P0 — #570).
*
* Public token guards (publicTokenGuards.loadActionToken) are the most
* security-sensitive surface in the CRM module — these are the routes
* a customer hits via the link in the quote email, reachable from any
* IP with the raw token. A regression here means leaked tokens become
* permanently usable, or worse, an expired token starts working again.
*
* Tests pin the contract documented in publicTokenGuards.js:
* - 404 on unknown token (and IP bad-attempt counter ticks)
* - 410 on expired token
* - 410 on NULL expiry (defensive — historical bug)
* - 429 after 20 invalid attempts from one IP
* - 200 + sanitised payload on valid token
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
// MUST set the test DB env BEFORE the first require of anything that
// pulls in db.js — knexfile reads TEST_DATABASE_PATH at module-init
// time. The helper's bootCrmDb also has to be called once per file
// because the db module is cached; calling it from a second describe
// would silently reuse (or kill) the first instance's connection pool.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubquotes-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
describe('publicQuotes routes', () => {
let db;
let cleanup;
let app;
let customerId;
let quoteId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
const inserted = await db('quotes').insert({
quote_number: 'Q-TEST-0001',
customer_account_id: customerId,
currency: 'CHF',
issue_date: new Date().toISOString().slice(0, 10),
net_amount_minor: 10000,
vat_amount_minor: 0,
total_amount_minor: 10000,
status: 'sent',
language: 'de',
created_at: new Date(),
}).returning('id');
quoteId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
// Clear the in-memory IP bad-attempts map between scenarios so the
// lockout test starts from a known state — and so it doesn't bleed
// 429s into the unrelated tests that follow.
beforeEach(() => {
if (tokenGuards._internal?.badAttempts) {
tokenGuards._internal.badAttempts.clear();
}
});
describe('GET /:token', () => {
it('returns 404 for an unknown but well-formed token', async () => {
const fakeToken = 'a'.repeat(64);
const res = await request(app).get(`/api/public/quotes/${fakeToken}`);
expect(res.status).toBe(404);
expect(res.body.error).toBeTruthy();
});
it('rejects malformed (non-64-hex) tokens with 400', async () => {
const res = await request(app).get('/api/public/quotes/not-a-real-token');
expect(res.status).toBe(400);
});
it('returns 410 for a token whose expires_at is in the past', async () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(410);
expect(res.body.code).toBe('TOKEN_EXPIRED');
});
// The NULL-expiry guard in loadActionToken is intentionally
// defensive but the current schema declares
// quote_action_tokens.expires_at NOT NULL — so the defensive
// branch is unreachable at the route level. Test it directly
// against loadActionToken in a unit suite if you want coverage.
it('returns 200 with a sanitised quote payload for a valid token', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId,
});
const res = await request(app).get(`/api/public/quotes/${token}`);
expect(res.status).toBe(200);
expect(res.body.quote).toBeDefined();
// API uses camelCase on the public view (see publicQuoteView in
// the route handler).
expect(res.body.quote.quoteNumber).toBe('Q-TEST-0001');
// Internal IDs / admin metadata must NOT appear on the public payload
expect(res.body.quote.customer_account_id).toBeUndefined();
expect(res.body.quote.customerAccountId).toBeUndefined();
expect(res.body.quote.createdByAdminId).toBeUndefined();
});
it('locks the IP after 20 invalid token lookups (429 TOKEN_LOOKUP_LOCKED)', async () => {
const fakeToken = 'b'.repeat(64);
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
const r = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(r.status).toBe(404);
}
const locked = await request(app)
.get(`/api/public/quotes/${fakeToken}`)
.set('X-Forwarded-For', '203.0.113.10');
expect(locked.status).toBe(429);
expect(locked.body.code).toBe('TOKEN_LOOKUP_LOCKED');
}, 30000);
});
describe('POST /:token/respond', () => {
it('rejects an invalid action (must be accept|decline) with 400', async () => {
const token = await createPublicToken(db, 'quote_action_tokens', { quote_id: quoteId });
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'maybe' });
expect(res.status).toBe(400);
});
it('returns 404 for an unknown token on respond', async () => {
const fakeToken = 'c'.repeat(64);
const res = await request(app)
.post(`/api/public/quotes/${fakeToken}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(404);
});
it('returns 410 when the token has expired (service-side check)', async () => {
// The POST path goes through quoteService.recordResponse rather
// than loadActionToken, so the error shape can differ from the
// GET expiry response — what matters is the HTTP status.
const past = new Date(Date.now() - 1000);
const token = await createPublicToken(db, 'quote_action_tokens', {
quote_id: quoteId, expires_at: past,
});
const res = await request(app)
.post(`/api/public/quotes/${token}/respond`)
.send({ action: 'accept' });
expect(res.status).toBe(410);
});
});
});
@@ -1,203 +0,0 @@
/**
* HTTP route tests for the ADMIN Live Slideshow endpoints:
* POST /api/admin/events/:id/slideshow/generate
* POST /api/admin/events/:id/slideshow/disable
* PATCH /api/admin/events/:id/slideshow
* PUT /api/admin/settings/slideshow (global preset + watermark + fit)
*
* Pins the contracts + the two regressions hit during the build:
* - the events table has NO `updated_at` column, so these writes must NOT set
* it (else every call 500s — that was the original "Generate" failure);
* - the `slideshow` feature flag gates these endpoints (403 when off);
* - PUT /admin/settings/slideshow validates + clamps every key.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-show-admin-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('admin Live Slideshow endpoints', () => {
let db; let cleanup; let app; let adminId; let token;
// Match slideshowPublic.test.js — bootCrmDb's full migration run intermittently
// exceeds Jest's default 5s `beforeAll` timeout on slower CI runners; raise
// it so this doesn't block PRs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await setFlag(db, 'slideshow', true);
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
describe('generate / disable', () => {
it('mints a share token (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(typeof res.body.show_share_token).toBe('string');
expect(res.body.show_share_token).toHaveLength(64);
expect(res.body.slideshow_url).toContain(`/show/${res.body.show_share_token}`);
const row = await db('events').where({ id }).first();
expect(row.show_share_token).toBe(res.body.show_share_token);
});
it('regenerate rotates the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'old-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(res.body.show_share_token).not.toBe('old-token');
});
it('disable nulls the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'live-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/disable`));
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_share_token == null).toBe(true);
});
it('403 when the slideshow feature is off', async () => {
const id = await insertEvent(db, adminId);
await setFlag(db, 'slideshow', false);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(403);
});
it('401 without an admin token', async () => {
const id = await insertEvent(db, adminId);
const res = await request(app).post(`/api/admin/events/${id}/slideshow/generate`);
expect(res.status).toBe(401);
});
});
describe('PATCH /:id/slideshow', () => {
it('persists display + watermark mode (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({
show_interval_ms: 9000,
show_transition: 'cut',
show_transition_ms: 300,
show_watermark: true,
show_colorfilter: 'bw',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_interval_ms).toBe(9000);
expect(row.show_transition).toBe('cut');
expect(row.show_transition_ms).toBe(300);
expect(row.show_colorfilter).toBe('bw');
expect(row.show_watermark === 1 || row.show_watermark === true).toBe(true);
});
it('show_watermark=null sets the column to NULL (inherit global)', async () => {
const id = await insertEvent(db, adminId, { show_watermark: 1 });
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_watermark: null });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_watermark == null).toBe(true);
});
it('400 on an invalid transition', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_transition: 'wormhole' });
expect(res.status).toBe(400);
});
});
describe('PUT /api/admin/settings/slideshow', () => {
const getSetting = async (key) => {
const row = await db('app_settings').where({ setting_key: key }).first();
return row ? JSON.parse(row.setting_value) : undefined;
};
it('persists the global preset + watermark + fit, clamping out-of-range values', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'contain',
slideshow_interval_ms: 9000,
slideshow_transition: 'slide',
slideshow_transition_ms: 250,
slideshow_colorfilter: 'sepia',
slideshow_watermark_enabled: true,
slideshow_watermark_opacity: 999, // clamp -> 100
slideshow_watermark_size: 99, // clamp -> 40
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('contain');
expect(await getSetting('slideshow_interval_ms')).toBe(9000);
expect(await getSetting('slideshow_transition')).toBe('slide');
expect(await getSetting('slideshow_transition_ms')).toBe(250);
expect(await getSetting('slideshow_colorfilter')).toBe('sepia');
expect(await getSetting('slideshow_watermark_enabled')).toBe(true);
expect(await getSetting('slideshow_watermark_opacity')).toBe(100);
expect(await getSetting('slideshow_watermark_size')).toBe(40);
});
it('coerces an invalid fit / transition to the safe default', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'banana',
slideshow_transition: 'wormhole',
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('cover');
expect(await getSetting('slideshow_transition')).toBe('crossfade');
});
});
});
@@ -1,286 +0,0 @@
/**
* HTTP route tests for the PUBLIC Live Slideshow surface (backend/src/routes/gallery.js):
* GET /:slug/show/:token/state (cheap settings + photo-count poll)
* GET /:slug/show/:token/session (mints the gallery JWT + cookie)
*
* These pin the two pieces of logic where real bugs lived during the build:
* - resolveSlideshow: the `slideshow` feature flag is a MASTER kill-switch
* (404 when off), plus token / expiry / draft / archived / inactive guards.
* - slideshowSettings: the watermark cascade (global look + per-event on/off),
* image fit, and the fact that globals are read from `app_settings`
* (regression for the getSetting→nonexistent-`settings`-table bug).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-show-pub-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
const SLUG = 'wedding-test';
const TOKEN = 'show-tok-abcdef';
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
async function setSetting(db, key, value, type = 'slideshow') {
await db('app_settings').where({ setting_key: key }).del();
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: type, updated_at: new Date() });
}
async function insertEvent(db, over = {}) {
const base = {
slug: SLUG,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
show_share_token: TOKEN,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('public Live Slideshow routes', () => {
let db; let cleanup; let app;
// bootCrmDb runs the full migration set against a fresh SQLite file, which
// takes <2s locally but has been observed to exceed Jest's default 5s
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
// block PRs on CI; doesn't affect happy-path local runs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = express();
app.use(express.json());
app.use(cookieParser());
// Both routers mount under /api/gallery in production; the display-only
// guard lives on download routes (gallery) + the feedback POST (galleryFeedback).
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await db('feature_flags').del();
invalidateFeatureFlagCache();
invalidateSlideshowGlobals();
await setFlag(db, 'slideshow', true);
});
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
describe('resolveSlideshow guards', () => {
it('200 + per-event display settings on a live link', async () => {
await insertEvent(db, {
show_interval_ms: 8000,
show_transition: 'kenburns',
show_transition_ms: 1200,
show_colorfilter: 'sepia',
});
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
interval_ms: 8000,
transition: 'kenburns',
transition_ms: 1200,
colorfilter: 'sepia',
fit: 'cover',
photo_count: 0,
watermark: null,
});
});
it('404 when the slideshow feature flag is OFF (master kill-switch)', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 on an unknown token', async () => {
await insertEvent(db);
const res = await request(app).get(stateUrl('not-the-token'));
expect(res.status).toBe(404);
});
it('404 when the share token is null (link never minted / disabled)', async () => {
await insertEvent(db, { show_share_token: null });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event has expired', async () => {
await insertEvent(db, { expires_at: new Date(Date.now() - 1000).toISOString() });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is a draft', async () => {
await insertEvent(db, { is_draft: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is archived', async () => {
await insertEvent(db, { is_archived: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
});
describe('slideshowSettings — image fit (global, live)', () => {
it('reflects the global slideshow_fit setting', async () => {
await insertEvent(db);
await setSetting(db, 'slideshow_fit', 'contain');
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body.fit).toBe('contain');
});
});
describe('slideshowSettings — watermark cascade (global look + per-event on/off)', () => {
async function enableGlobalWatermark() {
await setSetting(db, 'slideshow_watermark_enabled', true);
await setSetting(db, 'slideshow_watermark_source', 'logo');
await setSetting(db, 'slideshow_watermark_position', 'top-left');
await setSetting(db, 'slideshow_watermark_opacity', 40);
await setSetting(db, 'slideshow_watermark_style', 'original');
await setSetting(db, 'slideshow_watermark_size', 9);
await setSetting(db, 'branding_logo_url', '/uploads/logos/light.svg', 'branding');
}
it('inherits the global watermark when show_watermark is NULL', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toEqual({
url: '/uploads/logos/light.svg',
position: 'top-left',
opacity: 40,
style: 'original',
size: 9,
});
});
it('resolves the dark logo / favicon sources', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_source', 'favicon');
await setSetting(db, 'branding_favicon_url', '/uploads/favicons/f.png', 'branding');
const res = await request(app).get(stateUrl());
expect(res.body.watermark.url).toBe('/uploads/favicons/f.png');
});
it('per-event OFF override hides the watermark even when the global is on', async () => {
await insertEvent(db, { show_watermark: 0 });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
it('per-event ON override shows the watermark even when the global is off', async () => {
await insertEvent(db, { show_watermark: 1 });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_enabled', false);
const res = await request(app).get(stateUrl());
expect(res.body.watermark).not.toBeNull();
expect(res.body.watermark.url).toBe('/uploads/logos/light.svg');
});
it('null when enabled but no logo URL is configured', async () => {
await insertEvent(db, { show_watermark: null });
await setSetting(db, 'slideshow_watermark_enabled', true);
// no branding_logo_url set
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
});
describe('display-only token guards (#646 review concern 1)', () => {
// Mint a real slideshow JWT, then prove it is denied on the
// download / upload / feedback routes (display-only contract).
async function slideshowJwt() {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
return res.body.token;
}
it('403 on whole-gallery download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on single-photo download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download/1`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on bulk download-selected', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/download-selected`).set('Authorization', `Bearer ${jwt}`).send({ photoIds: [1] });
expect(res.status).toBe(403);
});
it('403 on feedback POST', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/photos/1/feedback`).set('Authorization', `Bearer ${jwt}`).send({ feedback_type: 'like' });
expect(res.status).toBe(403);
});
});
describe('GET /session', () => {
it('mints a token + sets the gallery cookie on a valid link', async () => {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(20);
expect(res.body.event).toMatchObject({ event_name: 'Test Wedding' });
expect(res.body).toHaveProperty('settings');
expect(res.body).toHaveProperty('photo_count', 0);
expect(res.headers['set-cookie']).toBeDefined();
});
it('404 when the feature is off', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(404);
});
});
});
@@ -1,219 +0,0 @@
/**
* Verifies the backup-integrity check covers every CRM document
* artefact column and correctly buckets each row into:
* - verifiedOk — file exists AND hash matches (when hash is stored)
* - missing — `*_path` set but file is not on disk
* - hashMismatches — file exists but bytes don't hash to `*_sha256`
* - existsButNoHash — file exists, no `*_sha256` column for this row
*
* Uses the CRM integration harness (bootCrmDb) so the schema +
* STORAGE_PATH wiring exactly mirrors production behaviour.
*
* Background: this service is the diagnostic for the
* `storage/business-docs/` gap fixed in the same PR — without it,
* a restored install would have audit-trail columns referencing
* files that no longer exist, but admins would have no way to see
* the breakage until a customer asked for their contract back.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
jest.setTimeout(30000);
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
let db;
let cleanup;
let customerId;
let storagePath;
let backupIntegrityService;
function seedFile(relPath, content) {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return { abs, relPath, sha: sha256(content) };
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
storagePath = process.env.STORAGE_PATH;
backupIntegrityService = require('../../src/services/backupIntegrityService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
// Wipe CRM rows between tests so each scenario sees a clean slate.
// Order matters: child tables before parents.
await db('invoice_line_items').del().catch(() => {});
await db('invoice_payment_log').del().catch(() => {});
await db('invoices').del().catch(() => {});
await db('quote_line_items').del().catch(() => {});
await db('quotes').del().catch(() => {});
await db('contracts').del().catch(() => {});
});
it('returns an empty report when no documents reference any path', async () => {
const report = await backupIntegrityService.verifyDocumentArtefacts();
expect(report.summary.totalRows).toBe(0);
expect(report.summary.verifiedOk).toBe(0);
expect(report.missing).toEqual([]);
expect(report.hashMismatches).toEqual([]);
expect(report.existsButNoHash).toEqual([]);
expect(report.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(report.scopes).toEqual(expect.arrayContaining(['quote', 'contract', 'contract-signature', 'invoice']));
});
it('flags a contract whose signed_pdf_path file is missing', async () => {
// Reference a file that we deliberately never create on disk.
// knex's `.returning('id')` returns `[{ id: N }]` on Postgres and
// newer SQLite, but `[N]` (plain int) on some SQLite versions —
// unwrap both shapes the same way the crmDb test harness does.
const inserted = await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-MISSING',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-2026-MISSING.pdf',
created_at: new Date(),
}).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
const hit = report.missing.find((m) => m.rowId === contractId);
expect(hit).toMatchObject({
table: 'contracts',
column: 'signed_pdf_path',
expectedPath: 'business-docs/contract/2026/C-2026-MISSING.pdf',
});
expect(report.summary.missingFiles).toBe(1);
});
it('verifies a contract whose file exists AND hash matches', async () => {
const { relPath, sha } = seedFile(
'business-docs/contract/2026/C-2026-OK.pdf',
'this is the signed contract content',
);
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-OK',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_pdf_path: relPath,
signed_pdf_sha256: sha,
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
expect(report.summary.verifiedOk).toBeGreaterThanOrEqual(1);
expect(report.summary.missingFiles).toBe(0);
expect(report.summary.hashMismatches).toBe(0);
});
it('flags a hash mismatch when the file exists but bytes differ from signed_pdf_sha256', async () => {
const { relPath } = seedFile(
'business-docs/contract/2026/C-2026-TAMPER.pdf',
'tampered bytes on disk',
);
const inserted = await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-TAMPER',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_pdf_path: relPath,
// Hash for completely different content — simulates tampering or
// bit-rot between sign-time and now.
signed_pdf_sha256: sha256('the ORIGINAL bytes the customer signed'),
created_at: new Date(),
}).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
const hit = report.hashMismatches.find((m) => m.rowId === contractId);
expect(hit).toBeDefined();
expect(hit.expectedSha).not.toBe(hit.actualSha);
expect(hit.column).toBe('signed_pdf_path');
});
it('buckets signature PNGs into existsButNoHash (no hash column)', async () => {
const { relPath } = seedFile(
'business-docs/contract/signatures/99/customer-1700000000000.png',
'\x89PNG\r\n\x1a\n', // doesn't have to be a real PNG, just bytes
);
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-SIG',
status: 'fully_signed',
issue_date: '2026-01-01',
signed_customer_signature_path: relPath,
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({
scope: ['contract-signature'],
});
expect(report.summary.existsButNoHash).toBeGreaterThanOrEqual(1);
expect(report.summary.verifiedOk).toBe(0); // no hash → not "verified ok"
expect(report.summary.missingFiles).toBe(0);
const hit = report.existsButNoHash.find((r) => r.column === 'signed_customer_signature_path');
expect(hit).toBeDefined();
});
it('respects the scope filter — contract scope skips quote/invoice tables', async () => {
// Seed an invoice with a missing pdf_path AND a contract with a
// missing signed_pdf_path. Scoping to contract should only flag
// the contract.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'INV-2026-SCOPE',
status: 'sent',
pdf_path: 'business-docs/invoice/2026/INV-2026-SCOPE.pdf',
issue_date: '2026-01-01',
due_date: '2026-01-31',
created_at: new Date(),
});
await db('contracts').insert({
customer_account_id: customerId,
contract_number: 'C-2026-SCOPE',
status: 'sent',
issue_date: '2026-01-01',
signed_pdf_path: 'business-docs/contract/2026/C-2026-SCOPE.pdf',
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
expect(report.scopes).toEqual(['contract']);
expect(report.missing.every((m) => m.table === 'contracts')).toBe(true);
expect(report.missing.some((m) => m.table === 'invoices')).toBe(false);
});
it('covers invoices.imported_pdf_path (admin-uploaded historical scans)', async () => {
// Imported invoices are the most catastrophic case — there's no
// renderer that can reproduce them. Verifier must check this column
// alongside invoices.pdf_path.
await db('invoices').insert({
customer_account_id: customerId,
invoice_number: 'IMP-2025-001',
status: 'sent',
imported_pdf_path: 'business-docs/invoice-imports/2025/legacy.pdf',
issue_date: '2025-06-01',
due_date: '2025-07-01',
created_at: new Date(),
});
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['invoice'] });
const hit = report.missing.find((m) => m.column === 'imported_pdf_path');
expect(hit).toBeDefined();
});
});
@@ -1,100 +0,0 @@
/**
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
*
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
* tuned for tracker scripts — defence-in-depth, even though the field is
* admin-only.
*/
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
describe('sanitizeTrackerSnippet (#663)', () => {
test('returns empty string for non-string / empty / whitespace input', () => {
expect(sanitizeTrackerSnippet(null)).toBe('');
expect(sanitizeTrackerSnippet(undefined)).toBe('');
expect(sanitizeTrackerSnippet(42)).toBe('');
expect(sanitizeTrackerSnippet('')).toBe('');
expect(sanitizeTrackerSnippet(' ')).toBe('');
});
test('passes through a Plausible-style script tag with data-domain', () => {
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://plausible.io/js/script.js"');
expect(out).toContain('data-domain="example.com"');
expect(out).toContain('defer');
});
test('passes through a Umami-style script with data-website-id', () => {
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://analytics.example.com/script.js"');
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
});
test('passes through inline script body unchanged', () => {
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('window.GA = "x"');
expect(out).toContain('console.log("init")');
});
test('allows <noscript> fallback', () => {
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('<noscript>');
});
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
const out = sanitizeTrackerSnippet(
'<link rel="preconnect" href="https://t.example.com">'
+ '<link rel="dns-prefetch" href="https://t.example.com">',
);
expect(out).toContain('rel="preconnect"');
expect(out).toContain('rel="dns-prefetch"');
expect(out).toContain('href="https://t.example.com"');
});
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
expect(out).not.toContain('stylesheet');
expect(out).not.toContain('href');
});
test('strips disallowed tags entirely', () => {
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('iframe');
expect(out).not.toContain('<div');
expect(out).not.toContain('<h1');
});
test('strips javascript: URLs from script src', () => {
const input = '<script src="javascript:alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('javascript:');
});
test('strips data: URLs from script src', () => {
const input = '<script src="data:text/javascript,alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('data:text/javascript');
});
test('strips on* event-handler attributes (defence-in-depth)', () => {
// event-handler attrs are not in our allowlist; sanitize-html strips them.
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('onload');
expect(out).toContain('src="https://t.example/x.js"');
});
test('returns empty string on unparseable input rather than throwing', () => {
// sanitize-html is fault-tolerant — pass deliberately malformed and
// confirm we don't blow up.
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
});
});
@@ -58,30 +58,13 @@ describe('resolveEffectiveRate', () => {
)).toBe(15000);
});
it('throws when override, customer rate, AND install default are all unset', () => {
it('throws when both override and customer rate are unset', () => {
expect(() => resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
null,
)).toThrow(/No hourly rate/);
});
it('falls back to the install-wide default when override + customer rate are unset', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
12000,
)).toBe(12000);
});
it('customer rate wins over the install-wide default', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
12000,
)).toBe(15000);
});
it('treats override=0 as "explicitly zero" (not null)', () => {
// Override === 0 is unusual but legal — pro bono blocks, internal
// tracking. Must NOT fall through to the customer default.
@@ -1,160 +0,0 @@
/**
* Unit tests for the accounting money logic — re-bill markup (incoming
* invoices) and internal-expense amount/build. Pure functions via _internal.
*/
const expenseService = require('../../src/services/expenseService');
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
describe('computeMarkupMinor', () => {
it('percent of base, rounded', () => {
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
});
it('flat / none', () => {
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
});
});
describe('resolveMarkup precedence (no contract / no DB)', () => {
it('override > source clause', async () => {
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
});
it("source clause when no override", async () => {
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
});
it('none when nothing set', async () => {
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
});
});
describe('computeExpenseAmount', () => {
it('mileage / per-diem = quantity x rate, rounded', () => {
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
});
it('amount = the entered minor amount', () => {
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
});
it('null when quantity or rate missing', () => {
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
});
});
describe('buildExpenseInsert (internal expense)', () => {
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
expect(row.kind).toBe('amount');
expect(row.disposition).toBe('eigener_aufwand');
expect(row.tax_treatment).toBe('domestic');
expect(row.status).toBe('open');
expect(row.chf_amount_minor).toBe(5000);
expect(row.created_by_admin_id).toBe(7);
expect(row.inbound_document_id).toBeNull();
});
it('mileage uses the override rate, else the settings km rate', () => {
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
expect(withDefault.rate_minor).toBe(70);
expect(withDefault.chf_amount_minor).toBe(2940);
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
expect(withOverride.rate_minor).toBe(100);
expect(withOverride.chf_amount_minor).toBe(4200);
});
it('per_diem uses days x per-diem rate', () => {
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
expect(row.rate_minor).toBe(8000);
expect(row.chf_amount_minor).toBe(16000);
});
it('event_id null = booked to company; proof path carried', () => {
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
expect(company.event_id).toBeNull();
expect(company.receipt_path).toBe('/p/x.pdf');
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
expect(evt.event_id).toBe(9);
});
});
describe('buildInboundLineItem (re-bill line)', () => {
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
expect(li.unit_price_minor).toBe(11000);
expect(li.line_total_minor).toBe(11000);
expect(li.quantity).toBe(1);
expect(li.description).toBe('ACME (Weiterverrechnung)');
});
it('passthrough: distinct suffix, no markup passes through at cost', () => {
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
expect(li.unit_price_minor).toBe(5000);
expect(li.description).toBe('SBB (Durchlaufende Position)');
});
it('falls back to net amount + generic label when total/supplier missing', () => {
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
expect(li.unit_price_minor).toBe(7300);
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
});
it('throws when there is no amount to re-bill', () => {
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
.toThrow(/no amount/i);
});
});
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
const reclaim = ['CH', 'LI'];
it('explicit valid treatment always wins', () => {
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
});
it('country in the reclaim list → domestic', () => {
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
});
it('country outside the reclaim list → foreign non-reclaimable', () => {
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
});
it('unknown / empty country falls back to domestic', () => {
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
});
it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
});
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
});
});
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
const future = new Date(Date.now() + 86400000).toISOString();
const past = new Date(Date.now() - 86400000).toISOString();
it('monthly draft and not-yet-armed scheduled are mutable', () => {
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
});
it('armed / issued invoices are locked', () => {
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
});
});
@@ -72,16 +72,9 @@ jest.mock('../../src/services/businessProfileService', () => ({
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/utils/documentSequences', () => ({
claimNextSequence: jest.fn(async () => 42),
}));
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
@@ -1,259 +0,0 @@
/**
* Smoke tests for invoiceService's primary flows ahead of the god-file
* decomposition — createInvoice happy path (incl. the line-item
* totals/VAT math), list/get reads, and the status-transition guards
* on cancelInvoice / releaseForDelivery.
*
* Uses the same deep-mocked db pattern as
* invoiceService.installmentPlan.test.js — chains are queued per table
* and assertions probe insert/update call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
jest.clearAllMocks();
}
const activeCustomer = {
id: 5, is_active: 1, feature_bills: 1,
billing_cadence: 'per_event', preferred_language: 'de',
};
describe('createInvoice — happy path + totals', () => {
beforeEach(() => resetChains());
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
pickChainFor('invoices')._insertResult = [{ id: 777 }];
const result = await invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 8.1,
lineItems: [
// 2 × 100.00 = 200.00
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
// 50.00 with 10% discount = 45.00
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
// Parent header — total auto-resolves from priced sub-items (350.00)
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
],
}, 1);
expect(result.invoiceIds).toEqual([777]);
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
// double-count. VAT = round(59500 × 8.1%) = 4820.
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
invoice_number: 'R-2026-0042',
customer_account_id: 5,
currency: 'CHF',
status: 'scheduled',
net_amount_minor: 59500,
vat_rate: 8.1,
vat_amount_minor: 4820,
shipping_amount_minor: 0,
total_amount_minor: 64320,
installment_total: 1,
}));
// Exactly one sequence number claimed for a single-row create.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
// Line items landed in invoice_line_items.
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
});
it('409s on a deactivated customer before touching the sequence', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
await expect(invoiceService.createInvoice({
customerAccountId: 5, vatRate: 0, lineItems: [],
}, 1)).rejects.toMatchObject({ statusCode: 409 });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
await expect(invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 7.7,
lineItems: [
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
],
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
});
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
beforeEach(() => resetChains());
it('lists invoices with total + pagination echo', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, invoice_number: 'R-2026-0001' },
{ id: 2, invoice_number: 'R-2026-0002' },
];
pickChainFor('invoices')._firstValue = { total: 7 };
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
expect(result.rows).toHaveLength(2);
expect(result.total).toBe(7);
expect(result.page).toBe(2);
expect(result.pageSize).toBe(10);
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
});
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
pickChainFor('invoice_line_items as li')._selectResult = [
{ id: 30, position: 1, description: 'Shoot' },
];
pickChainFor('invoice_payment_log')._selectResult = [];
const result = await invoiceService.getInvoiceById(3);
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
expect(result.lineItems).toHaveLength(1);
expect(result.payments).toEqual([]);
});
it('getInvoiceById returns null for an unknown id', async () => {
pickChainFor('invoices')._firstValue = undefined;
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
});
});
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
beforeEach(() => resetChains());
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
};
const result = await invoiceService.cancelInvoice(9, 1);
expect(result).toEqual({ cancelled: true, stornoId: null });
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'cancelled' })
);
});
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'cancelled', kind: 'invoice',
};
await expect(invoiceService.cancelInvoice(9, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
pickChainFor('invoices')._firstValue = {
id: 10, status: 'sent', kind: 'storno',
};
await expect(invoiceService.cancelInvoice(10, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
pickChainFor('invoices')._firstValue = {
id: 11, status: 'sent', kind: 'invoice',
};
await expect(invoiceService.releaseForDelivery(11, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
});
@@ -1,249 +0,0 @@
/**
* Tests for ledgerService (Accounting Layer A).
*
* Two layers:
* 1. Pure helpers (rateKey, csvEscape, minorToDecimal).
* 2. buildPostings + exportPostings — db chain + appSettings mocked so we can
* feed canned invoices/inbound/expenses and assert the Buchungssätze +
* the per-tool CSV shapes.
*/
// ----- canned data per table ------------------------------------------
let accountsRows = [];
let vatRows = [];
let invoiceRows = [];
let inboundRows = [];
let expenseRows = [];
function makeChain(rows) {
const c = {
_rows: rows,
then(onR, onJ) { return Promise.resolve(this._rows).then(onR, onJ); },
leftJoin() { return this; },
where() { return this; },
whereNot() { return this; },
whereIn() { return this; },
whereNotIn() { return this; },
whereBetween() { return this; },
whereRaw() { return this; },
orderBy() { return this; },
orderByRaw() { return this; },
modify(cb) { if (typeof cb === 'function') cb(this); return this; },
select() { return Promise.resolve(this._rows); },
first() { return Promise.resolve(this._rows[0]); },
};
return c;
}
const mockDbFn = jest.fn((table) => {
switch (table) {
case 'ledger_accounts': return makeChain(accountsRows);
case 'vat_codes': return makeChain(vatRows);
case 'invoices': return makeChain(invoiceRows);
case 'inbound_documents': return makeChain(inboundRows);
case 'expenses': return makeChain(expenseRows);
default: return makeChain([]);
}
});
mockDbFn.raw = (s) => s;
mockDbFn.schema = {
hasTable: jest.fn(async () => true),
hasColumn: jest.fn(async () => true),
};
jest.mock('../../src/database/db', () => ({ db: mockDbFn, withRetry: async (fn) => fn() }));
const SETTINGS = {
ledger_account_debitoren: '1100',
ledger_account_kreditoren: '2000',
ledger_account_default_revenue: '3400',
ledger_account_default_expense: '6700',
ledger_account_mileage: '6200',
ledger_account_per_diem: '6640',
ledger_account_rebilled_revenue: '3940',
ledger_vat_map: { domestic: 'VST81', reverse_charge_service: 'BZ', foreign_vat_non_reclaimable: 'VST00', import_goods: 'VST81' },
ledger_output_vat_map: { '8.1': 'UN81', '2.6': 'UN26', '3.8': 'UN38', '0': 'UN00' },
};
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async (key, def) => (key in SETTINGS ? SETTINGS[key] : def)),
}));
const ledgerService = require('../../src/services/ledgerService');
const { rateKey, csvEscape, minorToDecimal } = ledgerService._internal;
beforeEach(() => {
accountsRows = [
{ id: 1, number: '1100', name: 'Debitoren', type: 'asset' },
{ id: 2, number: '3400', name: 'Dienstleistungsertrag', type: 'revenue' },
{ id: 3, number: '2000', name: 'Kreditoren', type: 'liability' },
{ id: 4, number: '6570', name: 'Informatikaufwand', type: 'expense' },
{ id: 5, number: '6200', name: 'Fahrzeugaufwand', type: 'expense' },
{ id: 6, number: '6700', name: 'Sonstiger Betriebsaufwand', type: 'expense' },
];
vatRows = [{ id: 9, code: 'UN81', rate: 8.1, direction: 'output', account_id: null }];
invoiceRows = [];
inboundRows = [];
expenseRows = [];
});
// ----- pure helpers ----------------------------------------------------
describe('rateKey', () => {
it('normalises rate to the output-map key', () => {
expect(rateKey(8.1)).toBe('8.1');
expect(rateKey(8.10)).toBe('8.1');
expect(rateKey('2.60')).toBe('2.6');
expect(rateKey(0)).toBe('0');
expect(rateKey(null)).toBe('0');
});
});
describe('csvEscape / minorToDecimal', () => {
it('quotes + doubles inner quotes', () => {
expect(csvEscape('a,b')).toBe('"a,b"');
expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""');
expect(csvEscape(null)).toBe('""');
});
it('renders minor units as 2dp', () => {
expect(minorToDecimal(10810)).toBe('108.10');
expect(minorToDecimal(0)).toBe('0.00');
expect(minorToDecimal(null)).toBe('0.00');
});
});
// ----- buildPostings ---------------------------------------------------
describe('buildPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
it('books a revenue invoice as Dr Debitoren / Cr Ertrag with the output VAT code', async () => {
invoiceRows = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1,
net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '1100', debitName: 'Debitoren',
creditAccount: '3400', creditName: 'Dienstleistungsertrag',
grossMinor: 10810, netMinor: 10000, vatMinor: 810,
vatCode: 'UN81', source: 'revenue', eventName: 'Wedding A',
});
});
it('books an incoming invoice as Dr Aufwand(category) / Cr Kreditoren with the input VAT code', async () => {
inboundRows = [{
id: 5, invoice_number: 'L-77', invoice_date: '2026-01-12', created_at: '2026-01-13 09:00:00',
supplier_name: 'Lab AG', tax_treatment: 'domestic',
net_amount_minor: 2000, vat_amount_minor: 162, total_amount_minor: 2162,
event_id: 7, cat_account_id: 4, event_name: 'Wedding A',
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6570', creditAccount: '2000',
grossMinor: 2162, netMinor: 2000, vatMinor: 162,
vatCode: 'VST81', source: 'incoming', eventName: 'Wedding A',
});
});
it('falls back to the kind default account for a category-less mileage expense', async () => {
expenseRows = [{
id: 9, created_at: '2026-02-01 12:00:00', kind: 'mileage', supplier_name: null, description: 'Drive',
tax_treatment: 'foreign_vat_non_reclaimable', event_id: null,
original_amount_minor: null, chf_amount_minor: 5000,
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null,
}];
const { postings } = await ledgerService.buildPostings(period);
expect(postings).toHaveLength(1);
expect(postings[0]).toMatchObject({
debitAccount: '6200', creditAccount: '2000',
grossMinor: 5000, vatMinor: 0,
vatCode: 'VST00', source: 'expense', eventName: '',
});
});
it('sorts the combined journal chronologically across all sources', async () => {
invoiceRows = [{ id: 1, invoice_number: 'R1', issue_date: '2026-02-20', vat_rate: 8.1, net_amount_minor: 100, vat_amount_minor: 8, total_amount_minor: 108, customer_company_name: 'A' }];
inboundRows = [{ id: 5, invoice_number: 'L1', invoice_date: '2026-01-05', created_at: '2026-01-05', supplier_name: 'Lab', tax_treatment: 'domestic', net_amount_minor: 50, vat_amount_minor: 4, total_amount_minor: 54, event_id: null, cat_account_id: null }];
expenseRows = [{ id: 9, created_at: '2026-01-30', kind: 'amount', description: 'x', tax_treatment: 'domestic', event_id: null, chf_amount_minor: 200, net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null, cat_account_id: null }];
const { postings } = await ledgerService.buildPostings(period);
expect(postings.map((p) => p.source)).toEqual(['incoming', 'expense', 'revenue']);
});
it('requires from/to/currency', async () => {
await expect(ledgerService.buildPostings({})).rejects.toThrow(/from.+to/);
await expect(ledgerService.buildPostings({ from: '2026-01-01', to: '2026-03-31' })).rejects.toThrow(/currency/);
});
});
// ----- exportPostings --------------------------------------------------
describe('exportPostings', () => {
const period = { from: '2026-01-01', to: '2026-03-31', currency: 'CHF' };
beforeEach(() => {
invoiceRows = [{ id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-10', vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810, customer_company_name: 'ACME' }];
});
it('generic format carries all human-friendly columns', async () => {
const { content, filename, count } = await ledgerService.exportPostings({ ...period, format: 'generic' });
const [header, row] = content.trim().split('\r\n');
expect(count).toBe(1);
expect(header).toContain('DebitAccountName');
expect(header).toContain('NetAmount');
expect(header).toContain('VatCode');
expect(row).toContain('1100');
expect(row).toContain('108.10'); // gross 2dp
expect(filename).toMatch(/_generic\.csv$/);
});
it('banana format is a TAB-separated .txt with Banana column names', async () => {
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana' });
const header = content.split('\r\n')[0];
// Banana's "Text file with column headers" import wants TAB-separated,
// unquoted values in a .txt — not a comma CSV.
expect(header).toBe('Date\tDoc\tDescription\tAccountDebit\tAccountCredit\tAmount\tVatCode');
expect(content.split('\r\n')[1]).toContain('\t');
expect(content).not.toContain('"');
expect(filename).toMatch(/_banana\.txt$/);
expect(contentType).toMatch(/text\/plain/);
});
it('banana_ie format is Income & Expense columns, tab-separated .txt', async () => {
const { content, filename, contentType } = await ledgerService.exportPostings({ ...period, format: 'banana_ie' });
const [header, row] = content.trim().split('\r\n');
expect(header).toBe('Date\tDoc\tDescription\tIncome\tExpenses\tCategory\tVatCode');
// The mock period holds one revenue posting (gross 108.10) → Income filled,
// Expenses empty, Category = the revenue account.
const cells = row.split('\t');
expect(cells[3]).toBe('108.10'); // Income
expect(cells[4]).toBe(''); // Expenses
expect(cells[5]).not.toBe(''); // Category (revenue account)
expect(filename).toMatch(/_banana_ie\.txt$/);
expect(contentType).toMatch(/text\/plain/);
});
it('formats a Postgres Date object as yyyy-mm-dd (not "Thu Jan ...")', async () => {
// PG returns DATE columns as JS Date objects (SQLite returns strings); the
// export must still emit an ISO date, or Banana rejects it and the Date
// column imports empty.
invoiceRows = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: new Date(2026, 0, 10),
vat_rate: 8.1, net_amount_minor: 10000, vat_amount_minor: 810, total_amount_minor: 10810,
customer_company_name: 'ACME',
}];
const { content } = await ledgerService.exportPostings({ ...period, format: 'banana' });
const dateCell = content.split('\r\n')[1].split('\t')[0];
expect(dateCell).toBe('2026-01-10');
});
it('bexio format includes tax_code + currency', async () => {
const { content } = await ledgerService.exportPostings({ ...period, format: 'bexio' });
const header = content.split('\r\n')[0];
expect(header).toContain('tax_code');
expect(header).toContain('currency');
});
it('unknown format falls back to generic', async () => {
const { filename } = await ledgerService.exportPostings({ ...period, format: 'nope' });
expect(filename).toMatch(/_generic\.csv$/);
});
});
@@ -1,193 +0,0 @@
/**
* Unit tests for mfaService — admin TOTP MFA (#738).
*
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
* derivation has key material (the service derives the AES key from
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
*/
// Must be set BEFORE the service is required — the key is derived lazily per
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
const { authenticator } = require('otplib');
const mfaService = require('../../src/services/mfaService');
describe('mfaService — secret encryption (AES-256-GCM)', () => {
it('round-trips encrypt → decrypt to the original secret', () => {
const secret = mfaService.generateSecret();
const blob = mfaService.encryptSecret(secret);
expect(blob).toEqual(expect.any(String));
expect(blob).not.toContain(secret); // stored form is not plaintext
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
expect(mfaService.decryptSecret(blob)).toBe(secret);
});
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
const secret = mfaService.generateSecret();
const a = mfaService.encryptSecret(secret);
const b = mfaService.encryptSecret(secret);
expect(a).not.toBe(b);
expect(mfaService.decryptSecret(a)).toBe(secret);
expect(mfaService.decryptSecret(b)).toBe(secret);
});
it('throws when decrypting a malformed blob (wrong segment count)', () => {
expect(() => mfaService.decryptSecret('garbage')).toThrow();
expect(() => mfaService.decryptSecret('only.two')).toThrow();
});
it('throws when the auth tag / ciphertext is tampered with', () => {
const secret = mfaService.generateSecret();
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
// Flip a character in the ciphertext → GCM auth check must fail.
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
});
});
describe('mfaService — TOTP verification', () => {
it('accepts a freshly generated code for the plaintext secret', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotp(code, secret)).toBe(true);
});
it('tolerates whitespace in the submitted code', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
});
it('rejects a wrong code', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
const wrong = code === '000000' ? '111111' : '000000';
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
});
it('returns false for empty inputs rather than throwing', () => {
const secret = mfaService.generateSecret();
expect(mfaService.verifyTotp('', secret)).toBe(false);
expect(mfaService.verifyTotp('123456', '')).toBe(false);
expect(mfaService.verifyTotp(null, secret)).toBe(false);
});
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
const secret = mfaService.generateSecret();
const stored = mfaService.encryptSecret(secret);
const code = authenticator.generate(secret);
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
const wrong = code === '000000' ? '111111' : '000000';
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
});
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
const secret = mfaService.generateSecret();
const code = authenticator.generate(secret);
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
});
});
describe('mfaService — otpauth URI / QR', () => {
it('builds an otpauth:// URI containing issuer, account and secret', () => {
const secret = mfaService.generateSecret();
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
expect(uri).toMatch(/^otpauth:\/\/totp\//);
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
expect(uri).toContain(`secret=${secret}`);
});
it('builds a PNG data-URL QR for the URI', async () => {
const secret = mfaService.generateSecret();
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
const qr = await mfaService.buildQrDataUrl(uri);
expect(qr).toMatch(/^data:image\/png;base64,/);
});
});
describe('mfaService — recovery codes', () => {
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
expect(new Set(plain).size).toBe(10);
expect(new Set(hashed).size).toBe(10);
// Hashes are bcrypt, not the plaintext.
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
plain.forEach((p) => expect(hashed).not.toContain(p));
});
it('formats a raw code into 4-char groups', () => {
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
});
it('consumes a valid recovery code once and removes it (single-use)', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
const target = plain[3];
const first = await mfaService.consumeRecoveryCode(target, hashed);
expect(first.matched).toBe(true);
expect(first.remainingHashes).toHaveLength(9);
// Reusing the same code against the reduced set must now fail.
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
expect(reuse.matched).toBe(false);
expect(reuse.remainingHashes).toHaveLength(9);
});
it('matches case-insensitively and trims whitespace', async () => {
const { plain, hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
expect(res.matched).toBe(true);
});
it('rejects a wrong code and leaves the hash set unchanged', async () => {
const { hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
expect(res.matched).toBe(false);
expect(res.remainingHashes).toHaveLength(10);
});
it('handles empty / missing input safely', async () => {
const { hashed } = await mfaService.generateRecoveryCodes();
const res = await mfaService.consumeRecoveryCode('', hashed);
expect(res.matched).toBe(false);
expect(res.remainingHashes).toBe(hashed);
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
expect(noHashes.matched).toBe(false);
expect(noHashes.remainingHashes).toEqual([]);
});
});
describe('mfaService — parseRecoveryCodes', () => {
it('parses a JSON string array', () => {
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
});
it('passes an already-array through', () => {
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
});
it('returns [] for null / garbage / non-array JSON', () => {
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
});
});
describe('mfaService — isEnrolled coercion', () => {
it('treats true / 1 / "1" as enrolled', () => {
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
});
it('treats false / 0 / null / missing as not enrolled', () => {
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
expect(mfaService.isEnrolled({})).toBe(false);
expect(mfaService.isEnrolled(null)).toBe(false);
});
});
@@ -1,71 +0,0 @@
/**
* exportAsTxt — issue #623 regression test.
*
* The admin UI labels the TXT export "for Lightroom search". Lightroom's
* filename search wants ONE comma-separated line WITHOUT file extensions
* (the gallery JPEGs may map to RAW files in the catalog). The frontend
* now passes separator='comma' + include_extension=false for the TXT
* format; this test pins the resulting shape so a future refactor can't
* silently regress it back to the newline-separated form the bug reported.
*
* Also pins backward compatibility: a direct API caller passing no options
* still gets the original newline-with-extension behaviour, so existing
* integrations don't break.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/services/xmpGenerator', () => ({ XmpGenerator: class {} }));
const { PhotoExportService } = require('../../src/services/photoExportService');
const service = new PhotoExportService();
const PHOTOS = [
{ original_filename: 'IMG_0001.jpg', filename: 'abc123.jpg' },
{ original_filename: 'IMG_0002.JPEG', filename: 'def456.jpeg' },
{ original_filename: 'shoot.final.tif', filename: 'ghi789.tif' },
{ original_filename: null, filename: 'fallback.png' }, // null original → falls back to filename
];
describe('exportAsTxt (issue #623)', () => {
it('Lightroom mode: comma-joined, no extension, no space', () => {
const result = service.exportAsTxt(PHOTOS, {
separator: 'comma',
include_extension: false,
});
expect(result.content).toBe('IMG_0001,IMG_0002,shoot.final,fallback');
expect(result.contentType).toBe('text/plain');
});
it('backward compatible: no options → newline-joined with extensions', () => {
const result = service.exportAsTxt(PHOTOS);
expect(result.content).toBe(
'IMG_0001.jpg\nIMG_0002.JPEG\nshoot.final.tif\nfallback.png',
);
});
it('semicolon separator joins without a trailing space', () => {
const result = service.exportAsTxt(PHOTOS, {
separator: 'semicolon',
include_extension: false,
});
expect(result.content).toBe('IMG_0001;IMG_0002;shoot.final;fallback');
});
it('filename_format=picpeak uses photo.filename (hashed) instead of original', () => {
const result = service.exportAsTxt(PHOTOS, {
filename_format: 'picpeak',
separator: 'comma',
include_extension: false,
});
expect(result.content).toBe('abc123,def456,ghi789,fallback');
});
it('extension stripping uses only the last segment ("a.b.c" → "a.b")', () => {
// path.parse('shoot.final.tif').name === 'shoot.final' — Lightroom
// catalogs that store basenames like "shoot.final" still match.
const result = service.exportAsTxt(
[{ original_filename: 'shoot.final.tif', filename: 'x.tif' }],
{ separator: 'comma', include_extension: false },
);
expect(result.content).toBe('shoot.final');
});
});
@@ -1,10 +0,0 @@
const rasterizeService = require('../../src/services/rasterizeService');
// The page-range guard runs BEFORE any fs/pdftoppm work, so these reject
// without touching the binary or disk (PR #622 concern 6).
describe('getRenderedPagePath page-range guard', () => {
it.each([0, -1, 201, 1000, 1.5, NaN])('rejects out-of-range page %p', async (page) => {
await expect(rasterizeService.getRenderedPagePath(1, '/tmp/does-not-exist.pdf', page))
.rejects.toMatchObject({ statusCode: 400, code: 'PAGE_OUT_OF_RANGE' });
});
});
@@ -1,115 +0,0 @@
/**
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
* `umamiAdapter` test contract: missing config / URL shape / encoding /
* normalisation / unknown-bucket drop / failure modes.
*
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
* envelopes since their docs hint at minor v0 → v1 shape variation.
*/
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete', async () => {
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends Bearer auth', async () => {
mockJson([{ device: 'desktop', sessions: 10 }]);
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
expect(init.headers.Authorization).toBe('Bearer rkey');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ device: 'desktop', sessions: 1 }]);
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
});
test('normalises a typical {device, sessions} payload into percentages', async () => {
mockJson([
{ device: 'desktop', sessions: 60 },
{ device: 'mobile', sessions: 30 },
{ device: 'tablet', sessions: 10 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('accepts the {data: [...]} envelope variant', async () => {
mockJson({ data: [
{ device: 'desktop', sessions: 1 },
{ device: 'mobile', sessions: 3 },
] });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
});
test('falls back to `visitors` when `sessions` is absent', async () => {
mockJson([
{ device: 'desktop', visitors: 80 },
{ device: 'mobile', visitors: 20 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('tolerates a `dimension` key as the bucket label', async () => {
mockJson([
{ dimension: 'desktop', sessions: 50 },
{ dimension: 'mobile', sessions: 50 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
});
test('drops unknown buckets', async () => {
mockJson([
{ device: 'desktop', sessions: 80 },
{ device: 'mobile', sessions: 20 },
{ device: 'fridge', sessions: 100 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => ({
ok: true, status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
+12 -24
View File
@@ -22,26 +22,19 @@ function makeChain(initialRows) {
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
orderByRaw: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
}
const mockDbFn = jest.fn((tableName) => {
// Route by table name when supplied — the Skonto aggregate (added
// by migration 126) queries `invoice_payment_log`; the #4 cost side
// queries `inbound_documents` + `expenses`; everything else (main
// listing, replacements lookup) hits `invoices`.
if (tableName === 'invoice_payment_log') return makeChain([]);
if (tableName === 'inbound_documents') return makeChain([]);
if (tableName === 'expenses') return makeChain([]);
callCount += 1;
// Route by table name when supplied — the Skonto aggregate (added
// by migration 126) queries `invoice_payment_log`; everything else
// (main listing, replacements lookup) hits `invoices`.
if (tableName === 'invoice_payment_log') return makeChain([]);
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
@@ -49,10 +42,6 @@ const mockDbFn = jest.fn((tableName) => {
// COALESCE (migration 123). The chain's select() ignores its
// arguments so the raw() return value just needs to exist.
mockDbFn.raw = jest.fn((sql) => sql);
// #4 loadCosts schema-guards each cost table; default the PDF/CSV
// fixtures to "no accounting tables" so these renderers exercise the
// revenue path unchanged.
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
@@ -193,11 +182,8 @@ describe('renderTaxReportCsv', () => {
expect(contentType).toMatch(/text\/csv/);
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
const lines = content.split('\r\n');
// Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the
// old Rechnung/Kunde split.
expect(lines[0]).toContain('Typ'); // de header for tax_col_type
expect(lines[0]).toContain('Referenz'); // de header for tax_col_reference
expect(lines[0]).toContain('Kunde'); // "Kunde / Lieferant"
expect(lines[0]).toContain('Rechnung'); // de header for tax_col_invoice
expect(lines[0]).toContain('Kunde');
expect(lines[0]).toContain('Netto');
});
@@ -237,17 +223,19 @@ describe('renderTaxReportCsv', () => {
expect(content).toMatch(/"161\.55"/);
});
it('flags a cancelled row with a "(Cancelled)" suffix on its Reference cell', async () => {
it('marks cancelled rows with a 1 in the cancelled column', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ status: 'cancelled' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Unified ledger CSV has no separate cancelled column — a cancelled row is
// flagged by appending the localised "(Cancelled)" tag to its Reference.
// Migration 126 added a trailing Skonto column. The cancelled
// marker is now second-to-last; the Skonto cell is empty for
// non-Skonto rows. Asserting on a regex keeps the test stable
// against future trailing-column additions.
const dataRow = content.split('\r\n')[1];
expect(dataRow).toContain('R-2026-0001 (Cancelled)');
expect(/"1","[^"]*"$/.test(dataRow)).toBe(true);
});
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
@@ -22,9 +22,6 @@
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let inboundRowsForRun = [];
let expenseRowsForRun = [];
let costTablesPresent = false;
let callCount = 0;
function makeChain(initialRows) {
@@ -35,35 +32,24 @@ function makeChain(initialRows) {
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
orderByRaw: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
return c;
}
const mockDbFn = jest.fn((tableName) => {
callCount += 1;
// Migration 126 added a Skonto aggregate that hits
// `invoice_payment_log` — route those explicitly to an empty list so
// the test surface stays focused on the invoices/replacements flow.
if (tableName === 'invoice_payment_log') return makeChain([]);
// Cost side (#4): incoming invoices + internal expenses.
if (tableName === 'inbound_documents') return makeChain(inboundRowsForRun);
if (tableName === 'expenses') return makeChain(expenseRowsForRun);
// `invoices` is queried for the main listing (call 1) and, when there
// are cancelled rows, the replacements lookup (call 2).
callCount += 1;
// First call: main listing. Second call: replacements lookup.
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// loadCosts (#4) schema-guards each cost table. Default off so the
// revenue-only tests are unaffected; cost-side tests flip it on.
mockDbFn.schema = { hasTable: jest.fn(async () => costTablesPresent) };
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments and returns the mocked rows, so the raw() return value
@@ -81,9 +67,6 @@ const { grossUpLateFee, computeReportedAmounts, buildCustomerLabel } = taxReport
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
inboundRowsForRun = [];
expenseRowsForRun = [];
costTablesPresent = false;
callCount = 0;
mockDbFn.mockClear();
});
@@ -276,47 +259,6 @@ describe('getTaxReport', () => {
]);
});
it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => {
// The real cancel-and-reissue flow produces THREE rows in the period:
// the cancelled original, its negative Storno (kind='storno', status='sent'),
// and the reissue. Totals must read the reissued amount, not 0.
invoiceRowsForRun = [
{
id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02',
currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7,
net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03',
currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: 20,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
];
replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }];
const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' });
expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail
// The negative storno must NOT net against the totals (the cancelled
// original is already excluded) — the reissued revenue stands.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
invoiceRowsForRun = [
{
@@ -393,180 +335,4 @@ describe('getTaxReport', () => {
expect(out.totalsByVatRate).toEqual([]);
expect(out.cancelledCount).toBe(0);
});
it('returns an empty cost side + zeroed summary when accounting tables are absent', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'X',
},
];
costTablesPresent = false; // no accounting migrations on this DB
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs).toEqual({ rows: [], totalNet: 0, totalVat: 0, totalGross: 0, reclaimableVat: 0 });
expect(out.summary).toMatchObject({
incomeNetMinor: 10000, incomeVatMinor: 770, incomeGrossMinor: 10770,
costNetMinor: 0, costVatMinor: 0, costGrossMinor: 0,
resultNetMinor: 10000, resultGrossMinor: 10770,
// VAT registration unconfigured in the test DB → refuse to compute payable.
vatRegistrationConfigured: false, vatPayableMinor: null,
});
});
it('aggregates incoming invoices + expenses into the cost side and nets the result', async () => {
invoiceRowsForRun = [
{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 100000, vat_amount_minor: 7700, total_amount_minor: 107700,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
},
];
costTablesPresent = true;
// Incoming supplier invoice: net 20000 + vat 1540 = 21540.
inboundRowsForRun = [
{
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
net_amount_minor: 20000, vat_amount_minor: 1540, total_amount_minor: 21540,
event_name: 'Wedding A',
},
];
// Internal expense (mileage, no VAT split): only a CHF base amount.
expenseRowsForRun = [
{
id: 9, created_at: '2026-02-01 12:00:00',
supplier_name: null, description: 'Travel', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'open', event_id: null,
original_currency: null, original_amount_minor: null, chf_amount_minor: 5000,
net_amount_minor: null, vat_amount_minor: null, gross_amount_minor: null,
event_name: null,
},
];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs.rows).toHaveLength(2);
// Incoming invoice mapped + booked to the event.
const incoming = out.costs.rows.find((r) => r.source === 'incoming');
expect(incoming).toMatchObject({
supplierLabel: 'Lab AG', eventName: 'Wedding A',
netMinor: 20000, vatMinor: 1540, totalMinor: 21540,
});
// Expense: no net/vat/gross → falls back to the CHF base as total,
// and (company-booked) event name blank.
const expense = out.costs.rows.find((r) => r.source === 'expense');
expect(expense).toMatchObject({
eventName: '', netMinor: 5000, vatMinor: 0, totalMinor: 5000,
});
expect(out.costs.totalNet).toBe(25000);
expect(out.costs.totalVat).toBe(1540);
expect(out.costs.totalGross).toBe(26540);
// Summary nets income against costs.
expect(out.summary).toMatchObject({
incomeNetMinor: 100000, incomeVatMinor: 7700, incomeGrossMinor: 107700,
costNetMinor: 25000, costVatMinor: 1540, costGrossMinor: 26540,
resultNetMinor: 75000, resultGrossMinor: 81160,
vatRegistrationConfigured: false, vatPayableMinor: null,
});
});
it('excludes declined/duplicate incoming invoices via the query filter (sanity on chain wiring)', async () => {
costTablesPresent = true;
inboundRowsForRun = []; // the whereNotIn filter is applied in SQL; here we assert empty → zeroed
expenseRowsForRun = [];
const out = await taxReportService.getTaxReport({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(out.costs.totalGross).toBe(0);
expect(out.summary.costGrossMinor).toBe(0);
});
});
// ----- export scope (income/cost split) --------------------------------
describe('export scope helpers', () => {
const { scopeLedger, normalizeScope } = taxReportService._internal;
const ledger = [
{ type: 'outgoing', reference: 'R-1' },
{ type: 'incoming', reference: 'IN-1' },
{ type: 'expense', reference: 'EXP-1' },
];
it('normalizeScope defaults unknown/empty to "all"', () => {
expect(normalizeScope('all')).toBe('all');
expect(normalizeScope('income')).toBe('income');
expect(normalizeScope('cost')).toBe('cost');
expect(normalizeScope('bogus')).toBe('all');
expect(normalizeScope(undefined)).toBe('all');
});
it('scopeLedger income keeps only outgoing rows', () => {
expect(scopeLedger(ledger, 'income').map((r) => r.type)).toEqual(['outgoing']);
});
it('scopeLedger cost keeps incoming + expense rows', () => {
expect(scopeLedger(ledger, 'cost').map((r) => r.type)).toEqual(['incoming', 'expense']);
});
it('scopeLedger all keeps everything; null-safe', () => {
expect(scopeLedger(ledger, 'all')).toHaveLength(3);
expect(scopeLedger(null, 'income')).toEqual([]);
});
});
describe('renderTaxReportCsv scope', () => {
beforeEach(() => {
costTablesPresent = true;
invoiceRowsForRun = [{
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 8.1,
net_amount_minor: 100000, vat_amount_minor: 8100, total_amount_minor: 108100,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME', event_name: 'Wedding A',
}];
inboundRowsForRun = [{
id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00',
supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand',
tax_treatment: 'domestic', status: 'categorized', event_id: 7,
net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620,
event_name: 'Wedding A',
}];
});
it('income scope keeps the invoice row, drops the supplier cost row', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'income',
});
expect(content).toContain('R-2026-0001');
expect(content).not.toContain('Lab AG');
expect(filename).toContain('income_');
});
it('cost scope keeps the supplier row, drops the invoice row', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'cost',
});
expect(content).toContain('Lab AG');
expect(content).not.toContain('R-2026-0001');
expect(filename).toContain('cost_');
});
it('all scope (default) keeps both', async () => {
const { content, filename } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(content).toContain('R-2026-0001');
expect(content).toContain('Lab AG');
expect(filename).not.toMatch(/income_|cost_/);
});
});
@@ -1,91 +0,0 @@
/**
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
*
* Pins the contract that drives `adminDashboard.js` analytics route:
* - Returns null for 'none' / 'custom' / unset → route falls back to access_logs.
* - Returns an Umami adapter shape for provider='umami'.
* - Returns a Rybbit adapter shape for provider='rybbit'.
* - Back-compat: when `analytics_tracker_provider` is unset, infers
* 'umami' from the legacy `analytics_umami_enabled` flag.
* - Invalid provider strings fall through to the legacy back-compat path
* rather than crashing (defensive).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tracker-fact-')), 'db.sqlite',
);
const { bootCrmDb } = require('../integration/helpers/crmDb');
const trackers = require('../../src/services/trackers');
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('app_settings').del();
});
async function setSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'analytics',
updated_at: new Date(),
});
}
describe('resolveAdapter (#663)', () => {
test('returns null when provider=\'none\'', async () => {
await setSetting('analytics_tracker_provider', 'none');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
await setSetting('analytics_tracker_provider', 'custom');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
await setSetting('analytics_umami_enabled', true);
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter).not.toBeNull();
expect(adapter.provider).toBe('umami');
});
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'umami');
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('umami');
});
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'rybbit');
await setSetting('analytics_rybbit_url', 'https://r.example');
await setSetting('analytics_rybbit_website_id', 'r-1');
await setSetting('analytics_rybbit_api_key', 'rk-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('rybbit');
});
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
// No legacy umami_enabled → resolves to null (= 'none')
expect(await trackers.resolveAdapter()).toBeNull();
});
});
@@ -1,112 +0,0 @@
/**
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
* the old `umamiClient.test.js` from #662 — same contract, new shape).
*
* Pins the same 10 cases that protected the original implementation: missing
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
*/
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete (back-compat path)', async () => {
const a = buildAdapter({});
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
const b = buildAdapter({ baseUrl: 'https://u' });
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
mockJson([{ x: 'desktop', y: 10 }]);
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
await a.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toBe(
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
);
expect(init.headers['x-umami-api-key']).toBe('secret');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ x: 'desktop', y: 1 }]);
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
});
test('normalises { x, y } payload into integer percentages', async () => {
mockJson([
{ x: 'desktop', y: 60 },
{ x: 'mobile', y: 30 },
{ x: 'tablet', y: 10 },
]);
const a = buildAdapter(valid);
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
mockJson([
{ x: 'desktop', y: 50 },
{ x: 'laptop', y: 20 },
{ x: 'mobile', y: 30 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
});
test('drops unknown buckets (no silent miscategorisation)', async () => {
mockJson([
{ x: 'desktop', y: 80 },
{ x: 'mobile', y: 20 },
{ x: 'unknown-future-bucket', y: 100 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on non-2xx', async () => {
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on invalid JSON', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on network error', async () => {
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -27,7 +27,7 @@ function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
name: tag,
body,
published_at: publishedAt,
html_url: `https://github.com/PicPeak/picpeak/releases/tag/${tag}`,
html_url: `https://github.com/the-luap/picpeak/releases/tag/${tag}`,
};
}
@@ -58,7 +58,7 @@ describe('updateCheckService.getReleasesSince', () => {
tag: 'v3.55.0',
name: 'v3.55.0',
body: 'stable notes 3.55.0',
htmlUrl: 'https://github.com/PicPeak/picpeak/releases/tag/v3.55.0',
htmlUrl: 'https://github.com/the-luap/picpeak/releases/tag/v3.55.0',
});
});
@@ -1,146 +0,0 @@
/**
* Coverage for the activate + delete admin-user actions introduced as
* the #574 UI follow-up.
*
* Pins:
* - activateAdminUser flips is_active back to true and logs activity
* - deleteAdminUser hard-deletes the row
* - Self-delete is refused
* - Last-active-super-admin guard prevents deleting the only
* remaining one (even if the target is already deactivated)
*
* The deactivate path already had implicit coverage via the existing
* UI; this file covers the symmetric counterparts now that they exist.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-user-act-test-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'user-act-test-secret';
const { bootCrmDb, seedMinimal, assignAdminRole } = require('../integration/helpers/crmDb');
const userManagementService = require('../../src/services/userManagementService');
describe('userManagementService — activate + delete (#574 follow-up)', () => {
let db;
let cleanup;
let actorId; // The admin performing the actions (must be active + super_admin)
let targetId; // The admin we'll deactivate / reactivate / delete
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: actorId } = await seedMinimal(db));
await assignAdminRole(db, actorId, 'super_admin');
// Second admin to be the target of our actions. Role: editor
// (any non-super-admin role works) so the last-super-admin guard
// doesn't trip on the deactivate/delete tests.
const editor = await db('roles').where({ name: 'editor' }).first();
const targetInsert = await db('admin_users').insert({
username: 'target', email: 'target@example.com',
password_hash: 'x', role_id: editor?.id || null,
is_active: 1, created_at: new Date(),
}).returning('id');
targetId = targetInsert[0]?.id ?? targetInsert[0];
}, 60000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('activateAdminUser', () => {
it('flips is_active back to true when target is deactivated', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.activateAdminUser(targetId, actorId);
const row = await db('admin_users').where({ id: targetId }).first();
expect(row.is_active === true || row.is_active === 1).toBe(true);
});
it('is a no-op when target is already active', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 1, updated_at: new Date('2000-01-01') });
const before = await db('admin_users').where({ id: targetId }).first();
await userManagementService.activateAdminUser(targetId, actorId);
const after = await db('admin_users').where({ id: targetId }).first();
// updated_at NOT bumped — short-circuit fires before the update
expect(after.updated_at).toEqual(before.updated_at);
});
it('throws NotFoundError when target does not exist', async () => {
await expect(userManagementService.activateAdminUser(99999, actorId))
.rejects.toThrow(/not found|admin user/i);
});
it('writes an admin_user_activated activity log entry', async () => {
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.activateAdminUser(targetId, actorId);
const log = await db('activity_logs')
.where({ activity_type: 'admin_user_activated' })
.orderBy('id', 'desc')
.first();
expect(log).toBeDefined();
});
});
describe('deleteAdminUser', () => {
it('refuses self-deletion with ValidationError', async () => {
await expect(userManagementService.deleteAdminUser(actorId, actorId))
.rejects.toThrow(/own account/i);
// Actor still exists
const row = await db('admin_users').where({ id: actorId }).first();
expect(row).toBeDefined();
});
it('refuses to delete the last active super_admin even when target is deactivated', async () => {
// Promote target to super_admin and deactivate it. Now there's
// only ONE active super_admin (the actor). Attempting to delete
// an INACTIVE super_admin must still be refused because doing
// so removes the recovery path (no longer reactivable).
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const deactivatedSuperInsert = await db('admin_users').insert({
username: 'inactive-super', email: 'inactive-super@example.com',
password_hash: 'x', role_id: superRole.id, is_active: 0,
created_at: new Date(),
}).returning('id');
const inactiveSuperId = deactivatedSuperInsert[0]?.id ?? deactivatedSuperInsert[0];
// Actor is the ONLY active super_admin. Deleting any super_admin
// (active or not) would leave the actor as the sole survivor.
// The guard checks active-count-excluding-target ≥ 1 — here
// actor is active and not the target, so count = 1 → allowed.
await userManagementService.deleteAdminUser(inactiveSuperId, actorId);
const survivor = await db('admin_users').where({ id: inactiveSuperId }).first();
expect(survivor).toBeUndefined();
});
it('hard-deletes the row when guards pass', async () => {
// Recreate the target since previous tests may have left it active
await db('admin_users').where({ id: targetId }).update({ is_active: 0 });
await userManagementService.deleteAdminUser(targetId, actorId);
const row = await db('admin_users').where({ id: targetId }).first();
expect(row).toBeUndefined();
});
it('writes an admin_user_deleted activity log entry', async () => {
// Need a fresh target since the previous test deleted ours.
const editor = await db('roles').where({ name: 'editor' }).first();
const inserted = await db('admin_users').insert({
username: 'about-to-go', email: 'about-to-go@example.com',
password_hash: 'x', role_id: editor?.id || null,
is_active: 0, created_at: new Date(),
}).returning('id');
const id = inserted[0]?.id ?? inserted[0];
await userManagementService.deleteAdminUser(id, actorId);
const log = await db('activity_logs')
.where({ activity_type: 'admin_user_deleted' })
.orderBy('id', 'desc')
.first();
expect(log).toBeDefined();
});
});
});
@@ -1,58 +0,0 @@
/**
* Pins the date-merge fix in `adminDashboard.js` /analytics route
* (#661 Bug A). The merge previously failed on Postgres because pg's
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
* returns a string — the old `dateObj.date === row.date` comparison
* was false on Postgres so chartData stayed all-zero even with traffic.
*
* We test the normalisation helper here in isolation. The route-level
* integration is covered by the existing dashboard route test.
*/
// The helper is internal to the route file; reimport via a small wrapper
// so we don't need to export everything publicly.
const path = require('path');
const fs = require('fs');
const ROUTE_SRC = fs.readFileSync(
path.join(__dirname, '../../src/routes/adminDashboard.js'),
'utf8',
);
// Tiny evaluator that grabs the normaliseDateKey function definition from
// the route source so the test pins the actual shipping implementation,
// not a copy.
function extractNormaliseDateKey() {
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
// eslint-disable-next-line no-new-func
return new Function(`${match[0]}; return normaliseDateKey;`)();
}
const normaliseDateKey = extractNormaliseDateKey();
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
test('passes through a YYYY-MM-DD string unchanged', () => {
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
});
test('slices off a time component on a longer ISO string', () => {
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
});
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
const d = new Date('2026-06-22T12:34:56Z');
expect(normaliseDateKey(d)).toBe('2026-06-22');
});
test('returns null for null / undefined / empty', () => {
expect(normaliseDateKey(null)).toBeNull();
expect(normaliseDateKey(undefined)).toBeNull();
expect(normaliseDateKey('')).toBeNull();
});
test('coerces unexpected types via String() to avoid throwing', () => {
// We don't expect to receive a number from either driver, but the
// helper should not crash if it does — date merge will simply miss.
expect(normaliseDateKey(20260622)).toBe('20260622');
});
});
@@ -1,238 +0,0 @@
/**
* Unit tests for the per-weekday business-hours floor (migration 114).
* Exercises the pure snap logic against a fixed IANA zone so the results
* don't drift with the CI box's local timezone.
*
* All scenarios use Europe/Zurich (the regulatory-scope default).
*/
const {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal,
} = require('../../src/utils/businessHours');
const TZ = 'Europe/Zurich';
// MonFri 09:0018:00, weekend closed. Plain string-block storage shape.
const STANDARD = {
'1': [{ start: '09:00', end: '18:00' }],
'2': [{ start: '09:00', end: '18:00' }],
'3': [{ start: '09:00', end: '18:00' }],
'4': [{ start: '09:00', end: '18:00' }],
'5': [{ start: '09:00', end: '18:00' }],
'6': [],
'7': [],
};
// MonFri with a lunch break (09:0012:00, 13:0018:00).
const LUNCH = {
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'6': [],
'7': [],
};
const cfg = (schedule, overrides = {}) => ({
enabled: true,
timezone: TZ,
schedule,
...overrides,
});
// Build a UTC instant from a Zurich wall-clock so the assertions read in
// local terms. Reuses the module's own converter (covered separately).
function zurich(y, mo, d, hh, mi) {
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
}
function partsOf(date) {
const p = _internal.getZonedParts(date, TZ);
return [p.y, p.mo, p.d, p.hh, p.mi];
}
describe('parseHHMM / minutesToHHMM', () => {
it('parses valid times to minutes', () => {
expect(parseHHMM('09:00')).toBe(540);
expect(parseHHMM('00:00')).toBe(0);
expect(parseHHMM('23:59')).toBe(1439);
});
it('rejects malformed input', () => {
expect(parseHHMM('9:00')).toBeNull();
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('')).toBeNull();
expect(parseHHMM(null)).toBeNull();
});
it('round-trips minutesToHHMM', () => {
expect(minutesToHHMM(540)).toBe('09:00');
expect(minutesToHHMM(0)).toBe('00:00');
expect(minutesToHHMM(1439)).toBe('23:59');
});
});
describe('normaliseSchedule', () => {
it('parses, sorts, and drops invalid blocks', () => {
const out = normaliseSchedule({
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
});
expect(out['1']).toEqual([
{ start: '09:00', end: '12:00' },
{ start: '13:00', end: '18:00' },
]);
expect(out['2']).toEqual([]);
expect(out['3']).toEqual([]);
expect(out['7']).toEqual([]);
});
it('accepts [start,end] pair blocks and a JSON string', () => {
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
});
it('garbage input → all-empty week', () => {
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
});
});
describe('snapToBusinessHours — single window (MonFri 09:0018:00)', () => {
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
// 2026-06-02 is a Tuesday.
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('weekday inside window (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
it('Sunday 14:00 → Monday 09:00', () => {
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
// 2026-06-05 is a Friday.
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
const input = zurich(2026, 6, 2, 9, 0);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — lunch break (09:0012:00, 13:0018:00)', () => {
it('morning block (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('afternoon block (Tue 17:59) → unchanged', () => {
const input = zurich(2026, 6, 2, 17, 59);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('after close (Tue 19:00) → next day 09:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — per-day differing hours', () => {
const PERDAY = {
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
'2': [], // Tue closed
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
'4': [], '5': [], '6': [], '7': [],
};
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
});
describe('snapToBusinessHours — passthrough cases', () => {
it('floor disabled → unchanged even when outside hours', () => {
const input = zurich(2026, 6, 2, 2, 11);
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
.toBe(input.getTime());
});
it('empty schedule → unchanged (nothing to snap to)', () => {
const empty = normaliseSchedule(null);
const input = zurich(2026, 6, 7, 14, 0);
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
});
it('non-Date / invalid input is passed through untouched', () => {
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
const bad = new Date('not-a-date');
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
});
});
describe('_internal round-trips', () => {
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
const p = _internal.getZonedParts(d, TZ);
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
});
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
});
});
-19
View File
@@ -1,19 +0,0 @@
const { isUniqueViolation } = require('../../src/utils/dbErrors');
describe('isUniqueViolation (PR #622 blocker 2 race-safety detector)', () => {
it('true for Postgres SQLSTATE 23505', () => {
expect(isUniqueViolation({ code: '23505' })).toBe(true);
});
it('true for node-sqlite3 SQLITE_CONSTRAINT code', () => {
expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(true);
});
it('true for a better-sqlite3 "UNIQUE constraint failed" message', () => {
expect(isUniqueViolation({ message: 'UNIQUE constraint failed: received_emails.message_id' })).toBe(true);
});
it('false for unrelated errors and nullish', () => {
expect(isUniqueViolation({ code: '23503' })).toBe(false); // FK violation
expect(isUniqueViolation({ message: 'connection refused' })).toBe(false);
expect(isUniqueViolation(null)).toBe(false);
expect(isUniqueViolation(undefined)).toBe(false);
});
});
@@ -1,57 +0,0 @@
/**
* Regression coverage for the identity-preserving email normalization
* options (#574).
*
* express-validator's `.normalizeEmail()` applies provider-specific
* canonicalization by default — Gmail dot-stripping, +tag stripping,
* googlemail → gmail folding, etc. That breaks identity because login
* lookups expect the address as the user was invited with, not the
* canonicalized form.
*
* The tests below run validator.js's `normalizeEmail` (the same
* implementation express-validator delegates to) through the
* `IDENTITY_PRESERVING_NORMALIZE_EMAIL` options object and pin the
* behaviour we depend on:
* - dots preserved on Gmail
* - +tags preserved on Gmail / Outlook / Yahoo / iCloud
* - googlemail.com domain preserved (not folded to gmail.com)
* - local-part lowercased (still the default — safe and consistent)
*/
const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../src/utils/emailNormalization');
const norm = (email) => validator.normalizeEmail(email, IDENTITY_PRESERVING_NORMALIZE_EMAIL);
describe('IDENTITY_PRESERVING_NORMALIZE_EMAIL', () => {
it('preserves dots in the Gmail local-part (the #574 root cause)', () => {
expect(norm('john.doe@gmail.com')).toBe('john.doe@gmail.com');
expect(norm('j.o.h.n@gmail.com')).toBe('j.o.h.n@gmail.com');
});
it('preserves Gmail +tags (subaddresses)', () => {
expect(norm('john.doe+invoices@gmail.com')).toBe('john.doe+invoices@gmail.com');
});
it('does not fold googlemail.com to gmail.com', () => {
expect(norm('john.doe@googlemail.com')).toBe('john.doe@googlemail.com');
});
it('preserves Outlook +tags', () => {
expect(norm('jane+work@outlook.com')).toBe('jane+work@outlook.com');
});
it('preserves Yahoo -tags', () => {
expect(norm('jane-work@yahoo.com')).toBe('jane-work@yahoo.com');
});
it('preserves iCloud +tags', () => {
expect(norm('jane+receipts@icloud.com')).toBe('jane+receipts@icloud.com');
});
it('lowercases the local-part (default behaviour we keep)', () => {
// all_lowercase defaults true in validator.js. Local-parts are
// case-insensitive in practice on every major provider, and
// lowercasing keeps login lookup consistent.
expect(norm('John.Doe@Gmail.com')).toBe('john.doe@gmail.com');
});
});
@@ -1,240 +0,0 @@
/**
* Unit tests for the per-guest favorite/like cap (#655).
*
* Pins the contract of `feedbackService.submitFeedback` around the cap:
* - null / 0 cap means unlimited (back-compat for installs that don't
* enable the feature).
* - At-cap ADD returns `{ limit_reached, limit, current_count }` rather
* than inserting — the route layer translates that into the structured
* 403 the UI listens for.
* - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state.
* A guest at 10/10 can still free a slot.
* - Limit reduction (admin lowers 20 → 10 while a guest has 15 already)
* grandfathers existing rows — new adds blocked, removals always allowed.
* - Caps are per-feedback-type: filling the favorite quota doesn't block
* likes on the same photo, and vice versa.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-limit-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const EVENT_SLUG = 'cap-test-event';
const GUEST_A = 'guest-a-identifier';
const GUEST_B = 'guest-b-identifier';
let db;
let cleanup;
let eventId;
let photoIds;
async function setEventFeedbackSettings(overrides) {
const base = {
feedback_enabled: 1,
allow_ratings: 1,
allow_likes: 1,
allow_comments: 0,
allow_favorites: 1,
require_name_email: 0,
moderate_comments: 0,
show_feedback_to_guests: 1,
identity_mode: 'simple',
max_favorites_per_guest: null,
max_likes_per_guest: null,
...overrides,
};
const existing = await db('event_feedback_settings').where('event_id', eventId).first();
if (existing) {
await db('event_feedback_settings').where('event_id', eventId).update(base);
} else {
await db('event_feedback_settings').insert({
event_id: eventId,
...base,
created_at: new Date(),
updated_at: new Date(),
});
}
}
async function favorite(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'favorite',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
async function like(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Cap Test',
event_date: '2026-06-22',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${EVENT_SLUG}/share`,
share_token: 'cap-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
// Seed 15 photos so we can test caps comfortably up to that count.
photoIds = [];
for (let i = 1; i <= 15; i += 1) {
const r = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/cap/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(r[0]?.id ?? r[0]);
}
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where('event_id', eventId).del();
});
describe('per-guest favorite cap (#655)', () => {
test('null cap = unlimited (back-compat for installs without #655)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: null });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
expect(r.created).toBe(true);
}
});
test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 0 });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
}
});
test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
const r = await favorite(id);
expect(r.created).toBe(true);
}
const r11 = await favorite(photoIds[10]);
expect(r11.limit_reached).toBe(true);
expect(r11.limit).toBe(10);
expect(r11.current_count).toBe(10);
expect(r11.feedback_type).toBe('favorite');
});
test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
for (const id of photoIds.slice(0, 5)) {
await favorite(id);
}
const blocked = await favorite(photoIds[5]);
expect(blocked.limit_reached).toBe(true);
// Un-favorite one — toggle off path returns { removed: true }
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
// Now the previously-blocked slot fits
const after = await favorite(photoIds[5]);
expect(after.created).toBe(true);
});
test('limit reduction grandfathers existing rows; new adds blocked', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
await favorite(id);
}
// Admin lowers the cap to 5 while the guest already has 10
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
// Existing 10 stay
const count = await db('photo_feedback')
.where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A })
.count('* as c').first();
expect(parseInt(count.c, 10)).toBe(10);
// New adds blocked
const blocked = await favorite(photoIds[10]);
expect(blocked.limit_reached).toBe(true);
expect(blocked.limit).toBe(5);
expect(blocked.current_count).toBe(10);
// Removals still allowed
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
});
test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 3 });
for (const id of photoIds.slice(0, 3)) {
await favorite(id, GUEST_A);
}
expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true);
// Guest B starts at 0
for (const id of photoIds.slice(0, 3)) {
const r = await favorite(id, GUEST_B);
expect(r.created).toBe(true);
}
expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true);
});
});
describe('per-guest like cap (#655)', () => {
test('favorite cap does NOT block likes on the same photo (per-type)', async () => {
await setEventFeedbackSettings({
max_favorites_per_guest: 3,
max_likes_per_guest: null,
});
for (const id of photoIds.slice(0, 3)) {
await favorite(id);
}
expect((await favorite(photoIds[3])).limit_reached).toBe(true);
// Likes still unlimited
for (const id of photoIds.slice(0, 10)) {
const r = await like(id);
expect(r.created).toBe(true);
}
});
test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => {
await setEventFeedbackSettings({ max_likes_per_guest: 2 });
await like(photoIds[0]);
await like(photoIds[1]);
const r = await like(photoIds[2]);
expect(r.limit_reached).toBe(true);
expect(r.feedback_type).toBe('like');
expect(r.limit).toBe(2);
expect(r.current_count).toBe(2);
});
});
@@ -1,120 +0,0 @@
/**
* Pure-function tests for the slug validator in galleryShortUrlService.
* The validator is the security boundary for the `/s/<slug>` public
* route — bad shapes leak into a UNIQUE column that's used in URLs
* without further escaping, so the rules need to be tight.
*/
// Provide a minimal db stub so requiring the service doesn't crash —
// the validator path doesn't touch the DB.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn().mockResolvedValue(false),
}));
const {
validateSlug,
_RESERVED_SLUGS,
} = require('../../src/services/galleryShortUrlService');
describe('validateSlug', () => {
describe('accepts', () => {
test.each([
'sofia-graduation',
'sofia',
'a', // single char (alphanumeric)
'1', // single digit
'abc123',
'123-abc',
'sofia-2026-06-05',
'sofia-2026',
'a-b-c-d',
'wedding-2026',
'xK7p2'.toLowerCase(), // lowercase 5-char
'a'.repeat(64), // exactly at the limit
])('%j', (slug) => {
expect(validateSlug(slug)).toBeNull();
});
});
describe('rejects', () => {
test.each([
['', 'cannot be empty'],
[' ', 'cannot be empty'], // trimmed → empty
['-sofia', 'lowercase letters'], // leading hyphen
['sofia-', 'lowercase letters'], // trailing hyphen
['Sofia', 'lowercase letters'], // uppercase
['sofia_graduation', 'lowercase letters'], // underscore
['sofia.graduation', 'lowercase letters'], // dot
['sofia graduation', 'lowercase letters'], // space
['sofia/graduation', 'lowercase letters'], // slash (path traversal vector)
['sofia%20graduation', 'lowercase letters'],
['a'.repeat(65), 'at most 64'], // one over limit
])('%j → %s', (slug, expectedReason) => {
const result = validateSlug(slug);
expect(result).not.toBeNull();
expect(result.toLowerCase()).toContain(expectedReason);
});
test('null', () => {
expect(validateSlug(null)).toContain('must be a string');
});
test('undefined', () => {
expect(validateSlug(undefined)).toContain('must be a string');
});
test('number', () => {
expect(validateSlug(42)).toContain('must be a string');
});
test('object', () => {
expect(validateSlug({})).toContain('must be a string');
});
});
describe('reserved slugs', () => {
test.each([
'admin',
'api',
'auth',
'gallery',
'og',
'health',
's', // can't shadow the shortener itself
'login',
'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too
])('reserves %j', (slug) => {
expect(_RESERVED_SLUGS.has(slug)).toBe(true);
});
test('"admin" → rejected with "reserved" reason', () => {
// validateSlug short-circuits at the regex for slugs containing
// dots (favicon.ico fails the regex first). Test a clean
// alphanumeric reserved word.
const result = validateSlug('admin');
expect(result).toBe('short_slug is reserved');
});
});
describe('path-traversal + URL-injection vectors are rejected at the regex', () => {
test.each([
'../etc/passwd',
'foo/../bar',
'foo?query=1',
'foo#fragment',
'foo&bar',
'foo bar',
'foo<script>',
'foo>',
'foo"',
'foo\'',
'foo;rm -rf',
])('%j', (slug) => {
expect(validateSlug(slug)).not.toBeNull();
});
});
});
@@ -1,66 +0,0 @@
const { cleanNetMinor, exactLineMinor } = require('../../src/utils/invoiceRounding');
// Sum the per-line ROUNDED totals the way computeTotals / createInvoice do,
// so each test can compare "sum of rounded lines" against cleanNetMinor.
function roundedNet(items, parentKey = 'parent_position') {
return items
.filter((li) => li[parentKey] == null || li[parentKey] === '')
.reduce((s, li) => s + Math.round(li.line_total_minor), 0);
}
function mkLine(position, quantity, unitPriceMinor, extra = {}) {
const discount = extra.discount_percent || 0;
return {
position,
quantity,
unit_price_minor: unitPriceMinor,
discount_percent: discount,
line_total_minor: Math.round(Math.round(quantity * unitPriceMinor) * (1 - discount / 100)),
parent_position: extra.parent_position ?? null,
};
}
describe('cleanNetMinor — sub-cent reconciliation', () => {
it('reconciles the real 68h × 32.25 invoice (sum-of-lines 2193.02 → clean 2193.00)', () => {
const qtys = [5.25, 3.25, 5.25, 2.75, 2, 1, 1.75, 5, 5.25, 5.25, 2.75,
4.5, 3.5, 2.5, 4.5, 2, 1.75, 3.25, 1.75, 3.5, 1.25];
const items = qtys.map((q, i) => mkLine(i + 1, q, 3225));
expect(roundedNet(items)).toBe(219302); // sum of the 21 rounded lines
expect(cleanNetMinor(items)).toBe(219300); // full-precision, rounded once
expect(cleanNetMinor(items) - roundedNet(items)).toBe(-2); // the -0.02 drift
});
it('is a no-op when every line is already cent-exact (adjustment 0)', () => {
const items = [mkLine(1, 2, 5000), mkLine(2, 3, 4000)];
expect(cleanNetMinor(items)).toBe(roundedNet(items));
});
it('is rate-agnostic: mixed hourly rates reconcile to one clean net', () => {
const items = [mkLine(1, 2.5, 3225), mkLine(2, 1.25, 3225), mkLine(3, 3.5, 4850), mkLine(4, 1.75, 4850)];
// sum-of-lines = 80.63 + 40.31 + 169.75 + 84.88 = 375.57; clean = 375.56
expect(roundedNet(items)).toBe(37557);
expect(cleanNetMinor(items)).toBe(37556);
});
it('honours per-line discounts at full precision', () => {
const items = [mkLine(1, 3, 1000, { discount_percent: 33 })];
// exact = 3 × 1000 × 0.67 = 2010 exactly → clean 2010
expect(cleanNetMinor(items)).toBe(2010);
});
it('migration-119 hierarchy: a parent with priced sub-items derives from the children', () => {
// Parent (pos 1) has two priced sub-items; parent own price ignored.
const parent = mkLine(1, 1, 9999); // own price should NOT count
const subA = mkLine(2, 2.5, 3225, { parent_position: 1 });
const subB = mkLine(3, 1.75, 3225, { parent_position: 1 });
const items = [parent, subA, subB];
// exact children = (2.5 + 1.75) × 3225 = 4.25 × 3225 = 13706.25 → 13706
expect(cleanNetMinor(items)).toBe(13706);
// parent's own 9999 must not leak in
expect(cleanNetMinor(items)).not.toBe(9999);
});
it('exactLineMinor returns the un-rounded product', () => {
expect(exactLineMinor({ quantity: 2.5, unit_price_minor: 3225 })).toBeCloseTo(8062.5, 5);
});
});
@@ -1,113 +0,0 @@
/**
* Tests for the SSRF guard in `networkValidation.js`.
*
* Regression coverage for GHSA-wmjx-pc37-272r — the original `isPrivateIPv6`
* was a string-prefix check that missed NAT64 (`64:ff9b::/96` per RFC 6052,
* `64:ff9b:1::/48` per RFC 8215), so a webhook URL like
* `http://[64:ff9b:1::a9fe:a9fe]/` could reach 169.254.169.254 on instances
* with NAT64/DNS64 egress.
*/
const { validateExternalUrl, isPrivateIP } = require('../../src/utils/networkValidation');
describe('validateExternalUrl — NAT64 + embedded-IPv4 SSRF', () => {
describe('NAT64 well-known prefix (RFC 6052, 64:ff9b::/96)', () => {
test.each([
['http://[64:ff9b::a9fe:a9fe]/latest/meta-data/', 'AWS metadata via NAT64 hex'],
['http://[64:ff9b::169.254.169.254]/', 'AWS metadata via NAT64 mixed notation'],
['http://[64:ff9b::7f00:1]/', 'loopback via NAT64'],
['http://[64:ff9b::a00:1]/', '10.0.0.1 via NAT64'],
])('blocks %s (%s)', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('NAT64 local-use prefix (RFC 8215, 64:ff9b:1::/48)', () => {
test.each([
['http://[64:ff9b:1::a9fe:a9fe]/', 'AWS metadata via local-use NAT64'],
['http://[64:ff9b:1::169.254.169.254]/', 'AWS metadata via mixed notation'],
['http://[64:ff9b:1::7f00:1]/', 'loopback via local-use NAT64'],
['http://[64:ff9b:1:abcd::1]/', 'arbitrary host inside the /48'],
])('blocks %s (%s)', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => {
test.each([
'http://[::ffff:127.0.0.1]/',
'http://[::ffff:7f00:1]/',
'http://[::ffff:169.254.169.254]/',
'http://[::ffff:a9fe:a9fe]/',
'http://[::ffff:10.0.0.1]/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('deprecated IPv4-compatible IPv6 (::/96)', () => {
test('blocks ::127.0.0.1', () => {
expect(validateExternalUrl('http://[::127.0.0.1]/').valid).toBe(false);
});
test('blocks ::169.254.169.254', () => {
expect(validateExternalUrl('http://[::169.254.169.254]/').valid).toBe(false);
});
});
describe('existing IPv6 private-range coverage stays intact', () => {
test.each([
'http://[::1]/',
'http://[fc00::1]/',
'http://[fd12:3456:789a::1]/',
'http://[fe80::1]/',
'http://[feb0::1]/',
'http://[::]/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('public IPv6 hosts stay allowed', () => {
test.each([
'https://[2001:4860:4860::8888]/',
'https://[2606:4700:4700::1111]/',
'https://[2a00:1450:4001:830::200e]/',
])('allows %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(true);
});
});
describe('existing IPv4 private-range coverage stays intact', () => {
test.each([
'http://127.0.0.1/',
'http://10.0.0.1/',
'http://172.16.0.1/',
'http://192.168.0.1/',
'http://169.254.169.254/',
'http://0.0.0.0/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('blocked hostnames', () => {
test.each([
'http://localhost/',
'http://metadata.google.internal/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('fail-closed parsing', () => {
test('isPrivateIP returns true for non-string', () => {
expect(isPrivateIP(null)).toBe(true);
expect(isPrivateIP(undefined)).toBe(true);
expect(isPrivateIP(42)).toBe(true);
});
test('invalid URLs are rejected', () => {
expect(validateExternalUrl('not a url').valid).toBe(false);
expect(validateExternalUrl('').valid).toBe(false);
});
});
});
@@ -1,48 +0,0 @@
/**
* Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug.
*
* The event-create route seeds show_interval_ms/show_transition_ms from
* app_settings via an int-parse-and-clamp. The old inline guard
* (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself
* for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so
* NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL
* rejects NaN for integer columns ("invalid input syntax for type
* integer: NaN") while SQLite silently stores NULL — so POST
* /api/admin/events 500'd on PG whenever the slideshow settings rows
* were absent (getAppSetting returns its null default).
*/
const { clampIntOrUndefined } = require('../../src/utils/numericHelpers');
describe('clampIntOrUndefined', () => {
it('returns undefined for null (the getAppSetting missing-row default)', () => {
expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined();
});
it('returns undefined for undefined, empty string, and booleans', () => {
expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined();
});
it('returns undefined for non-numeric garbage', () => {
expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined();
expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined();
});
it('never returns NaN for any of the failure-mode inputs', () => {
for (const v of [null, undefined, '', true, false, 'x', {}, []]) {
const out = clampIntOrUndefined(v, 100, 5000);
expect(Number.isNaN(out)).toBe(false);
}
});
it('parses and clamps valid values', () => {
expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500);
expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500);
expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000);
expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000);
expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500);
});
});
@@ -1,37 +0,0 @@
const { neutralizeSpreadsheetFormula } = require('../../src/utils/spreadsheetSafe');
const { _internal } = require('../../src/services/ledgerService');
describe('neutralizeSpreadsheetFormula — CSV/Banana formula-injection defence (PR #622 blocker 1)', () => {
it.each([
['=', '=cmd|"/C calc"!A1'],
['+', '+1+1'],
['-', '-2+3'],
['@', '@SUM(1+1)'],
['tab', '\tSUM(A1)'],
['carriage-return', '\rSUM(A1)'],
])('prefixes a single quote when the cell starts with %s', (_label, payload) => {
const out = neutralizeSpreadsheetFormula(payload);
expect(out).toBe(`'${payload}`);
expect(out[0]).toBe("'");
});
it('leaves safe values untouched', () => {
expect(neutralizeSpreadsheetFormula('LBM-R-2026-0001')).toBe('LBM-R-2026-0001');
expect(neutralizeSpreadsheetFormula('Acme GmbH')).toBe('Acme GmbH');
expect(neutralizeSpreadsheetFormula('29.40')).toBe('29.40');
// A minus only mid-string is fine — only a LEADING risky char matters.
expect(neutralizeSpreadsheetFormula('Q-2026-0001')).toBe('Q-2026-0001');
});
it('coerces null/undefined to empty string', () => {
expect(neutralizeSpreadsheetFormula(null)).toBe('');
expect(neutralizeSpreadsheetFormula(undefined)).toBe('');
});
it('ledgerService.csvEscape applies the prefix AND the RFC-4180 quote wrap', () => {
// formula cell → prefixed then quote-wrapped
expect(_internal.csvEscape('=1+1')).toBe('"\'=1+1"');
// embedded quotes still doubled; safe value not prefixed
expect(_internal.csvEscape('a"b')).toBe('"a""b"');
});
});
-69
View File
@@ -1,69 +0,0 @@
const { parseWhatsNew } = require('../../src/utils/whatsNew');
describe('parseWhatsNew', () => {
it('prefers the curated <!-- whatsnew --> block', () => {
const body = [
'<!-- whatsnew -->',
'- Invoice drafts in list',
'- Bank transfer payments',
'<!-- /whatsnew -->',
'',
'### Features',
'* **invoices:** something long that should be ignored ([#1](http://x))',
].join('\n');
expect(parseWhatsNew(body)).toEqual(['Invoice drafts in list', 'Bank transfer payments']);
});
it('falls back to the Features section, stripping scope + commit links', () => {
const body = [
'## [3.73.0-beta.0](http://x) (2026-06-29)',
'',
'### Features',
'',
'* **dashboard:** revenue tile toggles 365 days ([d1c9e02](http://c))',
'* **invoices:** surface monthly drafts in the Bills list ([e457656](http://c))',
'',
'### Bug Fixes',
'',
'* **invoices:** add bank transfer ([e96ef4c](http://c))',
].join('\n');
expect(parseWhatsNew(body)).toEqual([
'revenue tile toggles 365 days',
'surface monthly drafts in the Bills list',
]);
});
it('decodes HTML entities release-please escapes into changelog text', () => {
const body = '### Features\n* **gallery:** supports A &amp; B &lt;tags&gt; &quot;quoted&quot; ([#1](http://x))';
expect(parseWhatsNew(body)).toEqual(['supports A & B <tags> "quoted"']);
});
it('trims a trailing "— implementation detail" clause to the headline', () => {
const body = '### Features\n* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](http://x))';
expect(parseWhatsNew(body)).toEqual(['branded URL shortener']);
});
it('leaves hyphenated words and dash-free bullets intact', () => {
const body = '### Features\n* **invoices:** mark-paid now supports bank transfer ([#2](http://x))';
expect(parseWhatsNew(body)).toEqual(['mark-paid now supports bank transfer']);
});
it('excludes Bug Fixes from the fallback', () => {
const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one';
expect(parseWhatsNew(body)).toEqual(['feature one']);
});
it('caps at 8 bullets and de-dups', () => {
const lines = Array.from({ length: 12 }, (_, i) => `- bullet ${i % 9}`);
const body = `<!-- whatsnew -->\n${lines.join('\n')}\n<!-- /whatsnew -->`;
const out = parseWhatsNew(body);
expect(out.length).toBe(8);
expect(new Set(out).size).toBe(8);
});
it('returns [] for empty / non-string input', () => {
expect(parseWhatsNew('')).toEqual([]);
expect(parseWhatsNew(null)).toEqual([]);
expect(parseWhatsNew(undefined)).toEqual([]);
});
});
@@ -1,136 +0,0 @@
/**
* Unit tests for the WhatsApp template-parameter selection (#647 follow-up).
*
* Pins:
* - parseTemplateParams sanitizes unknown / non-string / duplicate keys,
* and falls back to the default 5-slot shape on empty / malformed input.
* - buildComponents emits ONLY the listed slots, in the listed order, so
* a 2-parameter template (event_name + gallery_link) sends exactly 2
* positional values — the reporter's exact case from issue #647.
* - The legacy 5-slot default still works unchanged for installs that
* haven't reconfigured.
*/
const {
buildComponents,
parseTemplateParams,
DEFAULT_TEMPLATE_PARAMS,
} = require('../../src/services/whatsappProcessor');
const baseData = {
customer_name: 'Aisha',
event_name: 'Wedding 2026',
gallery_link: 'https://picpeak.example/wedding-2026',
gallery_password: 'StrongPass!',
expiry_date: '2026-12-31T00:00:00Z',
};
describe('parseTemplateParams', () => {
test('returns the default 5-slot shape for empty / null / undefined input', () => {
expect(parseTemplateParams('')).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams(null)).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams(undefined)).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('returns the default shape for malformed JSON', () => {
expect(parseTemplateParams('{not json')).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('returns the default shape when JSON parses to a non-array', () => {
expect(parseTemplateParams('"event_name"')).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams('{"a":1}')).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('preserves the reporter\'s 2-slot shape', () => {
const out = parseTemplateParams(JSON.stringify(['event_name', 'gallery_link']));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops unknown slot keys', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 'unknown_slot', 'gallery_link', '__proto__',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops duplicate slot keys (first wins)', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 'gallery_link', 'event_name',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops non-string entries', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 42, null, { a: 1 }, 'gallery_link',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('falls back to default when every entry is invalid', () => {
const out = parseTemplateParams(JSON.stringify([
'unknown_a', 'unknown_b', null, 7,
]));
expect(out).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('also accepts an already-parsed array (defensive)', () => {
const out = parseTemplateParams(['event_name', 'gallery_link']);
expect(out).toEqual(['event_name', 'gallery_link']);
});
});
describe('buildComponents', () => {
test('legacy default shape emits 5 positional values, gallery_ready order', () => {
const out = buildComponents(baseData, 'en_US');
expect(out).toHaveLength(5);
expect(out[0]).toBe('Aisha');
expect(out[1]).toBe('Wedding 2026');
expect(out[2]).toBe('https://picpeak.example/wedding-2026');
expect(out[3]).toBe('🔒 Password: StrongPass!');
// expiry date is locale-formatted but always non-empty for a valid date
expect(out[4]).toMatch(/\d{2}/);
});
test('reporter\'s 2-slot shape — event_name + gallery_link, in that order', () => {
const out = buildComponents(baseData, 'ar', ['event_name', 'gallery_link']);
expect(out).toEqual(['Wedding 2026', 'https://picpeak.example/wedding-2026']);
});
test('reorder: gallery_link first, event_name second', () => {
const out = buildComponents(baseData, 'en_US', ['gallery_link', 'event_name']);
expect(out).toEqual(['https://picpeak.example/wedding-2026', 'Wedding 2026']);
});
test('empty slot list emits an empty components array (admin opted into nothing)', () => {
const out = buildComponents(baseData, 'en_US', []);
expect(out).toEqual([]);
});
test('password_line uses the locale-specific label when included', () => {
const out = buildComponents(baseData, 'ar', ['password_line']);
expect(out).toEqual(['🔒 كلمة المرور: StrongPass!']);
});
test('password_line is empty when no real password is set', () => {
const out = buildComponents(
{ ...baseData, gallery_password: '' },
'en_US',
['password_line'],
);
expect(out).toEqual(['']);
});
test('password_line is empty for the "No password required" sentinel', () => {
const out = buildComponents(
{ ...baseData, gallery_password: 'No password required' },
'en_US',
['password_line'],
);
expect(out).toEqual(['']);
});
test('omits expiry_date when omitted from the slot list', () => {
const out = buildComponents(baseData, 'en_US', ['event_name']);
expect(out).toEqual(['Wedding 2026']);
});
});
+4 -34
View File
@@ -11,16 +11,9 @@ exports.up = async function(knex) {
// Initialize tables
await initializeDatabase();
// Create default admin user if none exists.
//
// Legacy path — only when ADMIN_PASSWORD is explicitly provided (keeps
// existing docker-compose installs working unchanged). When it is NOT set,
// we deliberately leave admin_users empty so the first-run setup wizard
// (setupService / /setup) creates the admin in-browser — no ADMIN_PASSWORD
// in .env. Existing deployments already ran this migration, so this only
// affects fresh installs.
// Create default admin user if none exists
const adminExists = await knex('admin_users').first();
if (!adminExists && process.env.ADMIN_PASSWORD) {
if (!adminExists) {
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
@@ -40,34 +33,11 @@ exports.up = async function(knex) {
// Try to save credentials to file, but don't fail if we can't
const dataDir = path.join(__dirname, '..', '..', 'data');
const setupInfoPath = path.join(dataDir, 'ADMIN_CREDENTIALS.txt');
// Detect a pending install-from-backup trigger. If one exists,
// these credentials are about to be obsoleted by the restore —
// the backup's admin row replaces this fresh-install one a few
// seconds from now. We still write the file (in case the
// restore fails and the fresh admin is the only way in) but
// annotate the top so admins reading the file after restore
// don't waste time trying credentials that no longer exist.
// Flagged on PR #596 review.
const fsSync = require('fs');
const backupRoot = process.env.BACKUP_ROOT || '/backup';
const triggerWillFire = fsSync.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))
|| fsSync.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL.txt'));
const restoreNotice = triggerWillFire ? `
⚠️ 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.
` : '';
const setupInfo = `
========================================
PicPeak Admin Credentials
========================================${restoreNotice}
========================================
Your admin account has been created with these credentials:
@@ -100,18 +100,8 @@ exports.up = async function(knex) {
// Add restore-related settings to app_settings
const restoreSettings = [
{
// Default ON so fresh installs can recover from disaster
// without the catch-22 documented in _restoreSettingsBoot.js
// (fresh-install admin user trips the "1 active admin" warning,
// which can only be overridden with force=true, which the wizard
// refused if this setting was false — exactly the moment an
// admin can least afford a SQL incantation). Flipped from false
// to true 2026-05-30. Existing installs that ran this migration
// with the OLD value will get auto-upgraded once by the boot
// self-heal in _restoreSettingsBoot.js — see the
// `restore_allow_force_auto_upgraded` guard there.
setting_key: 'restore_allow_force',
setting_value: JSON.stringify(true),
setting_value: JSON.stringify(false),
setting_type: 'restore'
},
{
@@ -1,7 +1,7 @@
/**
* Migration: Add Customer Accounts (recurring user logins)
*
* Implements the customer tier from discussion PicPeak/picpeak#354.
* Implements the customer tier from discussion the-luap/picpeak#354.
*
* Three new tables:
* - customer_accounts : the user record (email + bcrypt password)
@@ -1,392 +0,0 @@
/**
* Migration: seed Slovenian (sl) email-template translations.
*
* Covers all email templates currently seeded by PicPeak:
* - gallery delivery templates
* - administrator account templates
* - backup/restore notifications
* - update notifications
* - customer account templates
*
* Idempotent: checks (template_id, language) before inserting so re-runs
* are safe and do not overwrite rows that an admin may have edited in the UI.
*/
const TRANSLATIONS = {
admin_invitation: {
sl: {
subject: 'Povabljeni ste, da se pridružite PicPeak kot {{role_name}}',
body_html: `
<h2>Dobrodošli v PicPeak!</h2>
<p>Povabljeni ste, da se pridružite platformi PicPeak za deljenje fotografij kot <strong>{{role_name}}</strong>.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Vaša vloga:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">Ta vloga vam omogoča upravljanje in administracijo platforme za deljenje fotografij.</p>
</div>
<p>Za sprejem povabila in nastavitev računa kliknite spodnji gumb:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Sprejmi povabilo</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Pomembno:</strong> To povabilo poteče dne <strong>{{expires_at}}</strong>. Sprejmite ga pred tem datumom.</p>
</div>
<p>Če tega povabila niste pričakovali ali menite, da je bilo poslano pomotoma, lahko to e-pošto varno prezrete.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
Če zgornji gumb ne deluje, kopirajte to povezavo v brskalnik:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Lep pozdrav,<br>
ekipa PicPeak</p>`,
body_text: 'Dobrodošli v PicPeak!\n\nPovabljeni ste, da se pridružite platformi PicPeak za deljenje fotografij kot {{role_name}}.\n\nVaša vloga: {{role_name}}\nTa vloga vam omogoča upravljanje in administracijo platforme za deljenje fotografij.\n\nZa sprejem povabila in nastavitev računa obiščite povezavo:\n{{invite_link}}\n\nPOMEMBNO: To povabilo poteče dne {{expires_at}}. Sprejmite ga pred tem datumom.\n\nČe tega povabila niste pričakovali ali menite, da je bilo poslano pomotoma, lahko to e-pošto varno prezrete.\n\nLep pozdrav,\nekipa PicPeak',
},
},
admin_password_reset: {
sl: {
subject: 'Vaše PicPeak administratorsko geslo je bilo ponastavljeno',
body_html: `
<h2>Obvestilo o ponastavitvi gesla</h2>
<p>Pozdravljeni <strong>{{username}}</strong>,</p>
<p>Vaše administratorsko geslo za PicPeak je ponastavil sistemski administrator.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Vaši novi prijavni podatki:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Uporabniško ime:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Začasno geslo:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Varnostno obvestilo</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>To je začasno geslo. Po prijavi ga takoj spremenite.</li>
<li>Svojega gesla nikoli ne delite z drugimi.</li>
<li>Če ponastavitve gesla niste pričakovali, se takoj obrnite na sistemskega administratorja.</li>
</ul>
</div>
<p>Za prijavo v administratorski vmesnik kliknite spodnji gumb:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Prijava</a>
</div>
<p style="color: #666; font-size: 13px;">Po prijavi odprite nastavitve profila in geslo spremenite v varno geslo, ki ga poznate samo vi.</p>
<p>Lep pozdrav,<br>
ekipa PicPeak</p>`,
body_text: 'Obvestilo o ponastavitvi gesla\n\nPozdravljeni {{username}},\n\nVaše administratorsko geslo za PicPeak je ponastavil sistemski administrator.\n\nVaši novi prijavni podatki:\n- Uporabniško ime: {{username}}\n- Začasno geslo: {{new_password}}\n\nVARNOSTNO OBVESTILO:\n- To je začasno geslo. Po prijavi ga takoj spremenite.\n- Svojega gesla nikoli ne delite z drugimi.\n- Če ponastavitve gesla niste pričakovali, se takoj obrnite na sistemskega administratorja.\n\nZa prijavo v administratorski vmesnik obiščite: {{admin_login_url}}\n\nPo prijavi odprite nastavitve profila in geslo spremenite v varno geslo, ki ga poznate samo vi.\n\nLep pozdrav,\nekipa PicPeak',
},
},
archive_complete: {
sl: {
subject: 'Arhiviranje končano: {{event_name}}',
body_html: `<h2>Arhiviranje je končano</h2>
<p>Spoštovani {{host_name}},</p>
<p>Vaša fotogalerija "{{event_name}}" je bila uspešno arhivirana.</p>
<p><strong>Podrobnosti arhiva:</strong></p>
<ul>
<li>Število fotografij: {{photo_count}}</li>
<li>Velikost arhiva: {{archive_size}}</li>
<li>Datum arhiviranja: {{archive_date}}</li>
</ul>`,
body_text: 'Arhiviranje je končano\n\nSpoštovani {{host_name}},\n\nVaša fotogalerija "{{event_name}}" je bila uspešno arhivirana.\n\nFotografije: {{photo_count}}\nVelikost: {{archive_size}}\nDatum arhiviranja: {{archive_date}}',
},
},
backup_completed: {
sl: {
subject: 'Varnostna kopija uspešno ustvarjena',
body_html: `<h2>Varnostna kopija je bila uspešno ustvarjena</h2>
<p>Načrtovana varnostna kopija je bila uspešno zaključena.</p>
<p><strong>Povzetek varnostne kopije:</strong></p>
<ul>
<li>Začetek: {{start_time}}</li>
<li>Trajanje: {{duration}}</li>
<li>Število varnostno kopiranih datotek: {{files_count}}</li>
<li>Skupna velikost: {{total_size}}</li>
<li>Vrsta varnostne kopije: {{backup_type}}</li>
</ul>`,
body_text: 'Varnostna kopija je bila uspešno ustvarjena\n\nNačrtovana varnostna kopija je bila uspešno zaključena.\n\nZačetek: {{start_time}}\nTrajanje: {{duration}}\nŠtevilo varnostno kopiranih datotek: {{files_count}}\nSkupna velikost: {{total_size}}\nVrsta varnostne kopije: {{backup_type}}',
},
},
backup_failed: {
sl: {
subject: 'Varnostno kopiranje ni uspelo - potrebno je takojšnje ukrepanje',
body_html: `<h2>Varnostno kopiranje ni uspelo</h2>
<p>Načrtovano varnostno kopiranje ni uspelo in zahteva takojšnjo pozornost.</p>
<p><strong>Podrobnosti napake:</strong></p>
<ul>
<li>Začetek: {{start_time}}</li>
<li>Vrsta varnostne kopije: {{backup_type}}</li>
<li>Napaka: {{error_message}}</li>
</ul>
<p>Preverite sistemske dnevnike za več podrobnosti in čim prej odpravite težavo.</p>`,
body_text: 'Varnostno kopiranje ni uspelo\n\nNačrtovano varnostno kopiranje ni uspelo in zahteva takojšnjo pozornost.\n\nZačetek: {{start_time}}\nVrsta varnostne kopije: {{backup_type}}\nNapaka: {{error_message}}\n\nPreverite sistemske dnevnike za več podrobnosti.',
},
},
customer_gallery_assigned: {
sl: {
subject: 'V vašem računu je na voljo nova galerija',
body_html: `<h2>Dodeljen vam je bil dostop do novih galerij</h2>
<p>Pozdravljeni {{customer_name}},</p>
{{#if singular}}<p>Fotograf vam je pravkar dodelil dostop do nove galerije v vašem računu:</p>{{/if}}{{#if multiple}}<p>Fotograf vam je pravkar dodelil dostop do {{gallery_count}} novih galerij v vašem računu:</p>{{/if}}
{{gallery_list_html}}
<p style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_link}}" class="button">Odpri nadzorno ploščo</a>
</p>
<p style="font-size: 13px; color: #666;">Če gumb ne deluje, kopirajte to povezavo v brskalnik:<br>
<span style="word-break: break-all;">{{dashboard_link}}</span></p>`,
body_text: 'Dodeljen vam je bil dostop do novih galerij\n\nPozdravljeni {{customer_name}},\n\nFotograf vam je pravkar dodelil dostop do {{gallery_count}} nove galerije oziroma novih galerij v vašem računu:\n\n{{gallery_list_text}}\n\nOdpri nadzorno ploščo: {{dashboard_link}}',
},
},
customer_invitation: {
sl: {
subject: 'Povabljeni ste k dostopu do svojih fotogalerij',
body_html: `
<h2>Dobrodošli v svojih fotogalerijah</h2>
<p>Povabljeni ste, da ustvarite uporabniški račun, kjer boste lahko vse svoje galerije dogodkov pregledovali na enem mestu — brez ločenih povezav in gesel.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" class="button">Nastavi račun</a>
</div>
<p>To povabilo poteče dne {{expires_at}}. Če povezava ne deluje, jo kopirajte v brskalnik:</p>
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
<p>Če tega e-poštnega sporočila niste pričakovali, ga lahko varno prezrete.</p>`,
body_text: 'Dobrodošli v svojih fotogalerijah\n\nPovabljeni ste, da ustvarite uporabniški račun, kjer boste lahko vse svoje galerije dogodkov pregledovali na enem mestu — brez ločenih povezav in gesel.\n\nNastavi račun: {{invite_link}}\n\nTo povabilo poteče dne {{expires_at}}.\n\nČe tega e-poštnega sporočila niste pričakovali, ga lahko varno prezrete.',
},
},
customer_password_reset: {
sl: {
subject: 'Ponastavitev gesla za vaš uporabniški račun',
body_html: `<p>Pozdravljeni,</p>
<p>Vaš fotograf je sprožil ponastavitev gesla za vaš uporabniški račun.</p>
<p><a href="{{reset_link}}" class="button">Nastavi novo geslo</a></p>
<p>Ta povezava poteče dne {{expires_at}}.</p>
<p>Če tega niste pričakovali, lahko sporočilo prezrete — vaše trenutno geslo bo delovalo še naprej, dokler ne kliknete povezave.</p>`,
body_text: 'Ponastavitev gesla za vaš uporabniški račun\n\nVaš fotograf je sprožil ponastavitev gesla za vaš uporabniški račun.\n\nNastavi novo geslo: {{reset_link}}\n\nTa povezava poteče dne {{expires_at}}.\n\nČe tega niste pričakovali, lahko sporočilo prezrete — vaše trenutno geslo bo delovalo še naprej, dokler ne kliknete povezave.',
},
},
database_backup_completed: {
sl: {
subject: 'Varnostna kopija podatkovne baze uspešno ustvarjena',
body_html: `<h2>Varnostna kopija podatkovne baze je bila uspešno ustvarjena</h2>
<p>Načrtovano varnostno kopiranje podatkovne baze je bilo uspešno zaključeno.</p>
<p><strong>Povzetek varnostne kopije:</strong></p>
<ul>
<li>Vrsta varnostne kopije: {{backup_type}}</li>
<li>Trajanje: {{duration}}</li>
<li>Velikost datoteke: {{file_size}}</li>
<li>Razmerje stiskanja: {{compression_ratio}}</li>
<li>Pot do datoteke: {{file_path}}</li>
</ul>`,
body_text: 'Varnostna kopija podatkovne baze je bila uspešno ustvarjena\n\nNačrtovano varnostno kopiranje podatkovne baze je bilo uspešno zaključeno.\n\nVrsta varnostne kopije: {{backup_type}}\nTrajanje: {{duration}}\nVelikost datoteke: {{file_size}}\nRazmerje stiskanja: {{compression_ratio}}\nPot do datoteke: {{file_path}}',
},
},
database_backup_failed: {
sl: {
subject: 'Varnostno kopiranje podatkovne baze ni uspelo - kritično opozorilo',
body_html: `<h2>Varnostno kopiranje podatkovne baze ni uspelo</h2>
<p>Načrtovano varnostno kopiranje podatkovne baze ni uspelo in zahteva takojšnjo pozornost.</p>
<p><strong>Podrobnosti napake:</strong></p>
<ul>
<li>Vrsta varnostne kopije: {{backup_type}}</li>
<li>Časovni žig: {{timestamp}}</li>
<li>Napaka: {{error_message}}</li>
</ul>
<p>To je kritična težava, ki lahko vpliva na obnovitev po izpadu. Čim prej jo preverite.</p>`,
body_text: 'Varnostno kopiranje podatkovne baze ni uspelo\n\nNačrtovano varnostno kopiranje podatkovne baze ni uspelo.\n\nVrsta varnostne kopije: {{backup_type}}\nČasovni žig: {{timestamp}}\nNapaka: {{error_message}}\n\nTo je kritična težava - preverite jo čim prej.',
},
},
expiration_warning: {
sl: {
subject: 'Vaša fotogalerija bo kmalu potekla',
body_html: `<h2>Galerija bo kmalu potekla</h2>
<p>Spoštovani {{host_name}},</p>
<p>Vaša fotogalerija "{{event_name}}" bo potekla čez {{days_remaining}} dni.</p>
<p>Po poteku bo galerija arhivirana in gostom ne bo več dostopna.</p>
<p><a href="{{gallery_link}}">Odpri galerijo</a></p>`,
body_text: 'Galerija bo kmalu potekla\n\nSpoštovani {{host_name}},\n\nVaša fotogalerija "{{event_name}}" bo potekla čez {{days_remaining}} dni.\n\nGalerija: {{gallery_link}}',
},
},
gallery_created: {
sl: {
subject: 'Vaša fotogalerija je pripravljena!',
body_html: `<h2>Galerija je bila uspešno ustvarjena</h2>
<p>Spoštovani {{host_name}},</p>
<p>Vaša fotogalerija "{{event_name}}" je bila uspešno ustvarjena!</p>
<p><strong>Podrobnosti galerije:</strong></p>
<ul>
<li>Datum dogodka: {{event_date}}</li>
<li>Povezava do galerije: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Geslo: {{gallery_password}}</li>
<li>Velja do: {{expiry_date}}</li>
</ul>
<p>Povezavo in geslo lahko delite z gosti, da si bodo lahko ogledali in prenesli fotografije.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: 'Galerija je bila uspešno ustvarjena\n\nSpoštovani {{host_name}},\n\nVaša fotogalerija "{{event_name}}" je bila uspešno ustvarjena!\n\nPovezava do galerije: {{gallery_link}}\nGeslo: {{gallery_password}}\nVelja do: {{expiry_date}}',
},
},
gallery_expired: {
sl: {
subject: 'Vaša fotogalerija je potekla',
body_html: `<h2>Galerija je potekla</h2>
<p>Spoštovani {{host_name}},</p>
<p>Vaša fotogalerija "{{event_name}}" je potekla in ni več dostopna.</p>
<p>Fotografije so bile arhivirane. Če potrebujete dostop, se obrnite na administratorja na {{admin_email}}.</p>`,
body_text: 'Galerija je potekla\n\nSpoštovani {{host_name}},\n\nVaša fotogalerija "{{event_name}}" je potekla in ni več dostopna.\n\nKontakt: {{admin_email}}',
},
},
restore_completed: {
sl: {
subject: '✅ Obnovitev uspešno zaključena',
body_html: `<h2>Obnovitev je bila zaključena</h2>
<p>Obnovitev je bila uspešno zaključena.</p>
<h3>Podrobnosti:</h3>
<ul>
<li><strong>Vrsta obnovitve:</strong> {{restore_type}}</li>
<li><strong>Trajanje:</strong> {{duration}}</li>
<li><strong>Obnovljene datoteke:</strong> {{files_restored}}</li>
<li><strong>Velikost podatkovne baze:</strong> {{database_size}}</li>
</ul>
<p>Preverite, ali aplikacija deluje pravilno in ali so podatki dostopni.</p>`,
body_text: 'Obnovitev je bila uspešno zaključena\n\nVrsta obnovitve: {{restore_type}}\nTrajanje: {{duration}}\nObnovljene datoteke: {{files_restored}}\nVelikost podatkovne baze: {{database_size}}\n\nPreverite, ali aplikacija deluje pravilno in ali so podatki dostopni.',
},
},
restore_failed: {
sl: {
subject: '❌ Obnovitev ni uspela',
body_html: `<h2>Obnovitev ni uspela</h2>
<p>Obnovitev ni uspela in zahteva pozornost.</p>
<h3>Podrobnosti:</h3>
<ul>
<li><strong>Vrsta obnovitve:</strong> {{restore_type}}</li>
<li><strong>Napaka:</strong> {{error_message}}</li>
<li><strong>Časovni žig:</strong> {{timestamp}}</li>
</ul>
<p>Preverite sistemske dnevnike za več podrobnosti in odpravite težavo pred ponovnim poskusom.</p>`,
body_text: 'Obnovitev ni uspela\n\nObnovitev ni uspela in zahteva pozornost.\n\nVrsta obnovitve: {{restore_type}}\nNapaka: {{error_message}}\nČasovni žig: {{timestamp}}\n\nPreverite sistemske dnevnike za več podrobnosti in odpravite težavo pred ponovnim poskusom.',
},
},
version_update_available: {
sl: {
subject: 'Na voljo je posodobitev PicPeak: različica {{new_version}}',
body_html: `
<h2>Na voljo je nova različica PicPeak</h2>
<p>Dobra novica! Za vašo namestitev je na voljo nova različica PicPeak.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Trenutna različica:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Nova različica:</strong> {{new_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
</div>
<p>Priporočamo, da pred posodobitvijo ustvarite varnostno kopijo podatkovne baze in naloženih datotek.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Ogled opomb ob izdaji</a>
</div>
<p style="color: #666; font-size: 13px;">
Če gumb ne deluje, kopirajte to povezavo v brskalnik:<br>
<a href="{{release_notes_url}}" style="color: #5C8762; word-break: break-all;">{{release_notes_url}}</a>
</p>
<p>Lep pozdrav,<br>
ekipa PicPeak</p>`,
body_text: 'Na voljo je nova različica PicPeak\n\nDobra novica! Za vašo namestitev je na voljo nova različica PicPeak.\n\nTrenutna različica: {{current_version}}\nNova različica: {{new_version}}\nKanal: {{channel}}\n\nPriporočamo, da pred posodobitvijo ustvarite varnostno kopijo podatkovne baze in naloženih datotek.\n\nOpombe ob izdaji: {{release_notes_url}}\n\nLep pozdrav,\nekipa PicPeak',
},
},
version_update_test: {
sl: {
subject: '[TEST] PicPeak obvestilo o posodobitvi — preverjanje nastavitev',
body_html: `<h2>To je testno e-poštno sporočilo</h2>
<p>To sporočilo ste prejeli, ker je administrator na strani za obvestila o posodobitvah
v vaši namestitvi PicPeak kliknil <strong>Pošlji testno e-pošto</strong>.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Rezultat:</strong> Nastavitve e-pošte za obvestila o posodobitvah delujejo.</p>
</div>
<p>Če tega testa niste sprožili vi, preverite nedavno administratorsko aktivnost.</p>`,
body_text: 'To je testno e-poštno sporočilo\n\nTo sporočilo ste prejeli, ker je administrator na strani za obvestila o posodobitvah v vaši namestitvi PicPeak kliknil Pošlji testno e-pošto.\n\nRezultat: Nastavitve e-pošte za obvestila o posodobitvah delujejo.\n\nČe tega testa niste sprožili vi, preverite nedavno administratorsko aktivnost.',
},
},
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
if (!(await knex.schema.hasTable('email_template_translations'))) return;
const rows = await knex('email_templates')
.whereIn('template_key', Object.keys(TRANSLATIONS))
.select('id', 'template_key');
const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
let inserted = 0;
let skipped = 0;
let missing = 0;
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
const templateId = keyToId[key];
if (!templateId) {
missing += 1;
continue;
}
for (const [language, content] of Object.entries(perLocale)) {
const existing = await knex('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing) {
skipped += 1;
continue;
}
await knex('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
inserted += 1;
}
}
console.log(`107_seed_sl_email_template_translations: inserted=${inserted}, skipped=${skipped}, missing=${missing}`);
};
exports.down = async function(knex) {
// No-op: an admin may have hand-edited the `sl` rows in the Templates UI
// after this migration ran, and we cannot reliably distinguish seeded rows
// from edited rows. Remove them manually if rollback is truly required.
};
@@ -1,139 +0,0 @@
/**
* Migration 109 — config-driven backup walker.
*
* (Originally numbered 108 on bugfix/crm-backup. Renumbered to 109
* before merge because upstream/beta independently shipped
* 108_seed_sl_email_template_translations.js. The createTable is
* idempotent via `hasTable` guard and the seed uses
* `onConflict('path').ignore()`, so beta installs that ran the
* 108-named version of this file get a harmless no-op when 109
* runs against the already-seeded table.)
*
* Stage B of the three-stage backup-hardening plan. The file-backup
* walker (`getFilesToBackupInternal` in backupService.js) historically
* hard-coded its list of subdirectories: events/active, events/archived,
* thumbnails, previews, heroes, uploads, business-docs.
*
* That list is a footgun every time a new feature lands that drops
* artefacts under STORAGE_PATH/<something>/ — the maintainer has to
* remember to edit the walker, and there's no schema-level record of
* what *should* be backed up. The CRM rollout missed `business-docs`
* for ~6 months (#XXX) for exactly this reason.
*
* This migration introduces a `backup_paths` table that the walker
* reads at runtime. New features add a row; the walker picks them up
* automatically. The `feature_flag` column gates scans behind an
* existing app_settings boolean (e.g. `backup_include_archived`),
* mirroring how the previous `includeArchived` parameter worked.
*
* Columns:
* - path : relative to STORAGE_PATH, unique
* - include_in_default : on/off without deleting the row (so
* audit trail of "we used to back this up"
* is preserved)
* - feature_flag : nullable; when set, walker checks the
* same-named app_settings boolean before
* scanning. Matches the existing pattern
* used by `backup_include_archived`.
* - display_order : controls admin-UI listing order
* - description : human-readable purpose, shown in admin UI
*
* Defense-in-depth: the walker also keeps a hard-coded LEGACY_DEFAULTS
* fallback so that if this table is somehow empty (failed migration on
* an existing install, manual truncation), backups still cover the
* historical set instead of silently shipping nothing. The boot-time
* self-heal in `_backupPathsBoot.js` re-seeds missing default rows on
* every startup so newly-added defaults reach already-deployed
* installs without a follow-up migration.
*
* Idempotent: skips the createTable if it already exists, and the
* seed uses `onConflict('path').ignore()` so re-runs don't duplicate.
*/
const DEFAULT_PATHS = [
{
path: 'events/active',
include_in_default: true,
feature_flag: null,
display_order: 10,
description: 'Active gallery photo originals',
},
{
path: 'events/archived',
include_in_default: true,
feature_flag: 'backup_include_archived',
display_order: 20,
description: 'Archived gallery photo originals (gated by backup_include_archived)',
},
{
path: 'thumbnails',
include_in_default: true,
feature_flag: null,
display_order: 30,
description: 'Generated gallery thumbnails',
},
{
path: 'previews',
include_in_default: true,
feature_flag: null,
display_order: 40,
description: 'Lightbox preview tier (#492)',
},
{
path: 'heroes',
include_in_default: true,
feature_flag: null,
display_order: 50,
description: 'Gallery hero header images',
},
{
path: 'uploads',
include_in_default: true,
feature_flag: null,
display_order: 60,
description: 'Direct uploads root (wet-signature contracts, imported invoices, etc.)',
},
{
path: 'business-docs',
include_in_default: true,
feature_flag: null,
display_order: 70,
description: 'CRM PDFs, signature artefacts, admin-imported historical invoices',
},
];
exports.up = async function(knex) {
const exists = await knex.schema.hasTable('backup_paths');
if (!exists) {
await knex.schema.createTable('backup_paths', (t) => {
t.increments('id').primary();
t.string('path', 256).notNullable().unique();
t.boolean('include_in_default').notNullable().defaultTo(true);
t.string('feature_flag', 64).nullable();
t.integer('display_order').notNullable().defaultTo(100);
t.string('description', 256).nullable();
t.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
t.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
});
}
// Seed defaults — `onConflict('path').ignore()` so already-seeded rows
// (manual edits by admins, prior partial runs) survive untouched.
await knex('backup_paths')
.insert(DEFAULT_PATHS.map((row) => ({
...row,
created_at: new Date(),
updated_at: new Date(),
})))
.onConflict('path')
.ignore();
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('backup_paths');
};
// Exported so the self-heal boot helper can reuse the same authoritative
// list without re-declaring it. Tests also import this to assert the
// walker is reading from this source.
exports.DEFAULT_PATHS = DEFAULT_PATHS;
@@ -1,40 +0,0 @@
/**
* Migration: normalize the Liechtenstein country code from the
* colloquial vehicle-plate code `FL` to the ISO 3166-1 alpha-2 code
* `LI`.
*
* Background: the customer create/edit UI used to accept a free-text
* 2-char country code and the placeholder suggested `FL` for
* Liechtenstein. That code isn't ISO — the PDF renderer's locale-aware
* lookup (services/pdfService.js countryName) and the new country
* dropdown both key on ISO, so `FL` rows render as the bare code
* instead of "Liechtenstein". The dropdown now stores `LI`; this
* migration brings existing rows in line so they display correctly and
* match new records.
*
* Scope: customer_accounts.country_code and business_profile.country_code.
* Case-insensitive so a hand-entered `fl` is caught too. The free-text
* country_name override column is left untouched — it exists precisely
* for operators who want a custom display string.
*
* Idempotent: re-runs only touch rows still holding FL, so a second run
* is a no-op.
*/
async function normalizeColumn(knex, table) {
if (!(await knex.schema.hasTable(table))) return;
if (!(await knex.schema.hasColumn(table, 'country_code'))) return;
await knex(table)
.whereRaw('UPPER(country_code) = ?', ['FL'])
.update({ country_code: 'LI' });
}
exports.up = async function(knex) {
await normalizeColumn(knex, 'customer_accounts');
await normalizeColumn(knex, 'business_profile');
};
// Irreversible by design: once normalized to the ISO code there's no
// way to know which `LI` rows were originally `FL`, and reverting would
// reintroduce the non-ISO value the rest of the system can't read.
exports.down = async function() {};
@@ -1,47 +0,0 @@
/**
* Migration: backfill historical send/payment dates on already-imported
* invoices.
*
* Background: the invoice-import endpoint (POST /admin/invoices/import)
* used to stamp `sent_at` and `paid_at` with the moment of import
* (`new Date()`) rather than the document's own historical dates. The
* CRM dashboard's "Revenue · last 30 days" card keys on `paid_at`, so a
* year-old paid invoice imported today wrongly counted toward the
* rolling window. The route now anchors both timestamps to `issue_date`
* (with an optional explicit `paidAt`); this migration brings the rows
* imported under the old behaviour in line.
*
* Scope: rows with `imported_pdf_path` set — i.e. historical documents,
* never invoices issued by picpeak itself. For those, no real payment
* date was ever captured (the column held the import timestamp), so the
* issue date is the best available anchor. Note: `paid_at`/`sent_at` are
* operational timestamps, not part of the invoice's immutable legal
* content — correcting an import-time bug on them doesn't alter the
* issued document.
*
* Idempotent: re-runs just re-assign the same issue_date value.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
const cols = ['imported_pdf_path', 'issue_date', 'sent_at', 'paid_at'];
for (const c of cols) {
if (!(await knex.schema.hasColumn('invoices', c))) return;
}
// sent_at → issue_date for every imported row that has one.
await knex('invoices')
.whereNotNull('imported_pdf_path')
.whereNotNull('sent_at')
.update({ sent_at: knex.ref('issue_date') });
// paid_at → issue_date for imported rows that recorded a payment.
await knex('invoices')
.whereNotNull('imported_pdf_path')
.whereNotNull('paid_at')
.update({ paid_at: knex.ref('issue_date') });
};
// Irreversible by design: the original import-time stamps were wrong
// data, and there's no record of them to restore.
exports.down = async function() {};
@@ -1,34 +0,0 @@
/**
* Migration: per-customer Skonto opt-out.
*
* Background: invoices already carry a per-invoice `skonto_disabled`
* flag (migration 126). For B2B customers who negotiated "no early-
* payment discount" as a standing contract term, the admin had to tick
* that toggle on every single invoice. This adds a customer-level flag
* so the opt-out is set once and applies to all of that customer's
* invoices. The resolver chain becomes:
* customer.skonto_disabled → invoice.skonto_disabled →
* invoice snapshot → source-quote snapshot → global default.
*
* Default false so existing customers keep inheriting whatever Skonto
* the template / global default offers — no behaviour change on upgrade
* (see migration-preserve-existing-state guidance).
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
if (await knex.schema.hasColumn('customer_accounts', 'skonto_disabled')) return;
await knex.schema.alterTable('customer_accounts', (table) => {
table.boolean('skonto_disabled').notNullable().defaultTo(false);
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
if (!(await knex.schema.hasColumn('customer_accounts', 'skonto_disabled'))) return;
await knex.schema.alterTable('customer_accounts', (table) => {
table.dropColumn('skonto_disabled');
});
};
@@ -1,35 +0,0 @@
/**
* Migration: install-wide default hourly rate.
*
* Background: hour entries resolve a billing rate through
* entry.hourly_rate_minor_override → customer.hourly_rate_minor.
* When a customer had neither set, saving an entry hard-failed with
* HOURLY_RATE_REQUIRED — a confusing save-time error on the hours page.
* This adds an install-wide fallback so a single global rate covers
* every customer who hasn't been given an individual one. The chain
* becomes:
* entry override → customer rate → business_profile default → (CTA).
*
* Stored in minor units (matches customer_accounts.hourly_rate_minor).
* Nullable, default NULL: existing installs keep today's behaviour
* (no implicit rate) until the admin sets one — no surprise rate gets
* applied on upgrade (migration-preserve-existing-state guidance).
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor')) return;
await knex.schema.alterTable('business_profile', (table) => {
table.bigInteger('default_hourly_rate_minor');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor'))) return;
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('default_hourly_rate_minor');
});
};
@@ -1,61 +0,0 @@
/**
* Migration: configurable business hours on the business profile.
*
* Adds two columns to the singleton business_profile row (id=1):
*
* business_hours TEXT — JSON, per-ISO-weekday opening
* blocks. Shape:
* {"1":[{"start":"09:00","end":"12:00"},
* {"start":"13:00","end":"18:00"}],
* ...,"7":[]}
* Keys are ISO weekdays 1=Mon … 7=Sun;
* a day with no blocks is closed.
* Multiple blocks per day model lunch
* breaks (Google-style).
* scheduled_email_floor_enabled BOOLEAN default TRUE — master switch for
* holding scheduled emails until the
* next open block.
*
* business_hours defaults to NULL (no hours configured). A null / empty
* schedule makes the scheduled-email floor a no-op, so existing installs
* keep today's behaviour — emails send at their requested instant until
* the admin actually defines opening hours (migration-preserve-state).
*
* The timezone the blocks are interpreted in is the EXISTING
* business_profile.timezone column (added earlier for the admin calendar)
* — no new tz column. The whole business-hours definition lives on the
* business profile, which is where the admin edits it.
*
* Idempotent: each column guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'business_hours'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.text('business_hours');
});
}
if (!(await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.boolean('scheduled_email_floor_enabled').notNullable().defaultTo(true);
});
}
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('scheduled_email_floor_enabled');
});
}
if (await knex.schema.hasColumn('business_profile', 'business_hours')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('business_hours');
});
}
};
@@ -1,31 +0,0 @@
/**
* Migration: admin decline-quote reason.
*
* Background: admins can now decline a quote on the customer's behalf
* ("customer told us by phone they're not going ahead") instead of
* waiting for the public response link. This stores an optional free-text
* reason alongside the existing `declined_at` timestamp so the quote
* detail page can show WHY it was declined.
*
* Nullable, no default — existing declined rows simply carry no reason,
* which is exactly how customer-side declines already look. No behaviour
* change on upgrade.
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'decline_reason')) return;
await knex.schema.alterTable('quotes', (table) => {
table.text('decline_reason');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'decline_reason'))) return;
await knex.schema.alterTable('quotes', (table) => {
table.dropColumn('decline_reason');
});
};
@@ -1,37 +0,0 @@
/**
* Migration: backfill paid_amount_minor for imported PAID invoices that
* stored 0.
*
* Background: the historical-invoice import only sent paidAmountMinor when
* the admin separately filled the "paid amount" field. Left blank (easy to
* miss — the total was already entered), it stored paid_amount_minor = 0
* even with status='paid'. The dashboard revenue windows sum
* paid_amount_minor (not total), so those paid imports contributed NOTHING
* to revenue. The import route now defaults a blank paid amount to the
* total; this fixes the rows already created before that change.
*
* Scope: imported (imported_pdf_path set) + status='paid' + paid_amount_minor
* 0/null → set paid_amount_minor = total_amount_minor (fully paid). Operational
* payment field, not immutable legal content (same reasoning as migration 111).
*
* Idempotent: re-running sets the same value; rows already > 0 are untouched.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (!(await knex.schema.hasColumn('invoices', 'imported_pdf_path'))) return;
if (!(await knex.schema.hasColumn('invoices', 'paid_amount_minor'))) return;
await knex('invoices')
.whereNotNull('imported_pdf_path')
.where('status', 'paid')
.andWhere(function() {
this.where('paid_amount_minor', 0).orWhereNull('paid_amount_minor');
})
.update({ paid_amount_minor: knex.raw('total_amount_minor') });
};
exports.down = async function() {
// Irreversible data backfill — we can't tell which rows we changed apart
// from legitimately-full payments. No-op.
};
@@ -1,90 +0,0 @@
/**
* Migration: Projects — an admin-only grouping layer ABOVE events
* (Project Overview cockpit, Model A).
*
* A project groups one OR MORE events of (usually) one customer; all the
* money documents (quotes/contracts/invoices) stay attached to their EVENT
* and the project simply rolls them up. Customers never see projects.
*
* projects id, name, customer_account_id (nullable), status,
* timestamps.
* events.project_id FK → projects (nullable, SET NULL on project delete).
*
* Backfill: every existing event gets its OWN auto-created project (the
* 1:1 default) so nothing is unassigned; admins then relink freely (group
* several events under one project, move events between projects). The
* auto-project's customer = the event's single assigned customer when there
* is exactly one, else NULL (admin sets it later). Cardinality is 1:N — the
* per-event auto-project is only the starting point, never a hard rule.
*
* Idempotent: table + column guarded; backfill touches only events whose
* project_id is still NULL, so a re-run is a no-op.
*/
exports.up = async function (knex) {
// 1. projects table
if (!(await knex.schema.hasTable('projects'))) {
await knex.schema.createTable('projects', (table) => {
table.increments('id').primary();
table.string('name', 255).notNullable();
// Nullable: a multi-customer or not-yet-assigned project has no single
// customer. SET NULL so erasing a customer doesn't delete the project.
table.integer('customer_account_id').unsigned()
.references('id').inTable('customer_accounts').onDelete('SET NULL');
table.string('status', 24).notNullable().defaultTo('active');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index(['customer_account_id']);
});
}
// 2. events.project_id
if ((await knex.schema.hasTable('events')) && !(await knex.schema.hasColumn('events', 'project_id'))) {
await knex.schema.alterTable('events', (table) => {
table.integer('project_id').unsigned()
.references('id').inTable('projects').onDelete('SET NULL');
table.index(['project_id']);
});
}
// 3. Backfill one auto-project per still-unassigned event. Wrapped in a
// single transaction so a heavy install (10k+ events) can't be left
// half-assigned if the loop dies mid-way — it's all-or-nothing, and a
// re-run still no-ops (gated on whereNull('project_id')).
if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) {
const hasAssignments = await knex.schema.hasTable('event_customer_assignments');
await knex.transaction(async (trx) => {
const events = await trx('events').whereNull('project_id').select('id', 'event_name');
for (const ev of events) {
let customerId = null;
if (hasAssignments) {
const rows = await trx('event_customer_assignments')
.where({ event_id: ev.id })
.select('customer_account_id');
if (rows.length === 1) customerId = rows[0].customer_account_id;
}
const name = (ev.event_name && String(ev.event_name).trim()) || `Event ${ev.id}`;
const inserted = await trx('projects').insert({
name,
customer_account_id: customerId,
status: 'active',
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
}).returning('id');
const projectId = (inserted[0] && typeof inserted[0] === 'object') ? inserted[0].id : inserted[0];
await trx('events').where({ id: ev.id }).update({ project_id: projectId });
}
});
}
};
exports.down = async function (knex) {
if ((await knex.schema.hasTable('events')) && (await knex.schema.hasColumn('events', 'project_id'))) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('project_id');
});
}
if (await knex.schema.hasTable('projects')) {
await knex.schema.dropTable('projects');
}
};
@@ -1,30 +0,0 @@
/**
* Migration: book logged hours to a project.
*
* Adds customer_hour_entries.project_id (nullable FK → projects, SET NULL).
* Hours stay primarily customer-scoped; the optional project link powers the
* "book to project" checkbox + the Project Overview hours roll-up. Null =
* not booked to a project (existing behaviour preserved).
*
* Idempotent: column guarded by hasColumn.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('customer_hour_entries'))) return;
if (!(await knex.schema.hasColumn('customer_hour_entries', 'project_id'))) {
await knex.schema.alterTable('customer_hour_entries', (table) => {
table.integer('project_id').unsigned()
.references('id').inTable('projects').onDelete('SET NULL');
table.index(['project_id']);
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('customer_hour_entries'))) return;
if (await knex.schema.hasColumn('customer_hour_entries', 'project_id')) {
await knex.schema.alterTable('customer_hour_entries', (table) => {
table.dropColumn('project_id');
});
}
};

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