Commit Graph

1536 Commits

Author SHA1 Message Date
Luca 1eaef67c36 feat(workflows): execution engine core + registry + tests
Graph executor that walks nodes/edges per run: trigger, condition/branch
(registered conditions → yes/no edge), bounded loop (counter in context +
maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate
(status=waiting; resumed via confirm/deny edge), action/webhook (registered
handlers). emitWorkflowEvent creates one idempotent run per matching enabled
workflow (unique dedup_key) and fails CLOSED if the flag system is
unavailable; never throws into callers (safe to call after commit). Every
node records a workflow_run_steps row. Registry seeds primitive
conditions (always/never/expr) + actions (noop/log/set_context). Integration
test covers loop+wait resume, gate confirm, and dedup.
2026-06-23 02:05:34 +02:00
Luca c818c25cf2 feat(workflows): schema + permissions (migration 142)
Adds the workflow engine's graph data model — workflows, workflow_nodes,
workflow_edges (versioned so in-flight runs keep their version),
workflow_runs (status/current_node/context, wake_at for the scheduler,
unique dedup_key for idempotency), workflow_run_steps (per-node audit),
and workflow_approvals (hashed email confirm/deny token + webview inbox).
Seeds workflows.view / workflows.manage and grants them to super_admin +
admin. Loose-FK integers per the whatsapp_queue/expenses convention;
idempotent hasTable guards + reversible down().
2026-06-23 01:57:06 +02:00
Luca ff478619b5 feat(workflows): add workflows feature flag + Features-tab toggle
New opt-in 'workflows' master flag (default off) across the backend
KNOWN_FLAGS/DEFAULT_FLAGS and the frontend FeatureKey union, context
defaults, and a new Automation section card in the Features tab. Gates
the upcoming Workflows admin area and the engine runtime. en/de i18n
added (DE native).
2026-06-23 01:53:03 +02:00
Paul Nothaft a1f68bc081 Merge pull request #659 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
chore(beta): release 3.69.0-beta.0
v3.69.0-beta.0
2026-06-22 22:55:35 +02:00
github-actions[bot] f9690cb568 chore(beta): release 3.69.0-beta.0 2026-06-22 20:51:07 +00:00
Paul Nothaft 3ac70177ef Merge pull request #658 from the-luap/feat/feedback-per-guest-limits-655
feat(feedback): per-guest favorite + like caps with mobile-friendly limit modal (#655)
2026-06-22 22:50:45 +02:00
Paul Nothaft 98e97e3cf2 fix(i18n): replace ASCII quote with U+201D in DE perGuestLimitsDesc
CI's frontend test job failed with "Failed to parse JSON file, invalid
JSON syntax found at position 163854" on de.json:3041. The German
description used „…" — the opening „ (U+201E) was correct, but the
closing was an ASCII " (U+0022) which the JSON parser treated as the
string terminator, leaving "-Abläufe..." as garbage outside the string.

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

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

## Schema (migration 141)

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

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

## Backend

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

## Frontend — the popup

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

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

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

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

## Admin UI

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

## i18n

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

## Tests

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

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

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

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

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

Three layers of defense:

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

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

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

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

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

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

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

## Schema (migration 138)

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

## Processor

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

## Admin route

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

## UI

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

## Tests

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

## Migration numbering

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

## Honest caveat

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

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

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

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

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

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

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

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

Three surgical fixes, mirroring the #317 logoUrl pattern:

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

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

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

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

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

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

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

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

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