New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):
- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
/ Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
received_emails (listReceived). Customers folders show an explanatory empty
state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
Book-as-expense-Re-bill) present but disabled — wired in later phases.
Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.
Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.
- Migration 153: hero_logo_size nullable + backfill NULL so existing
galleries inherit the global size (restores GalleryLayout's prior
live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.
Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).
Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.
- Migration 152: make events.hero_logo_visible nullable and NULL out the
defaulted rows so existing galleries follow the global going
forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
(GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
Always show / Always hide (en + de).
Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.
HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
flat /thumbnails/thumb_* file, so a visitor to one gallery could
enumerate another (password-protected) gallery's entire thumbnail set.
Scope thumbnail access to the token's event via photos.thumbnail_path.
Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
event ids with no owner filter (single-event routes enforce
requireEventOwnership), letting admin/editor archive or cascade-delete
any event. Add filterOwnedEventIds; also guard rename + import-external;
tighten photo-retry to scope admin (not just editor). Fix misleading
bulk-delete comment.
MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
(#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
to absolute/external URLs — only attach to relative same-app paths.
LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.
Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
- 92 mutations across 40 files moved to useMutationWithToast
(success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
to avoid introducing new error toasts
When the chosen features need config the wizard can collect, 'Finish' on
the usage step now advances to a lean config step instead of jumping to
the dashboard:
- Invoicing (if Invoices): company/legal name, address, VAT-ID or tax
number, IBAN, currency → saved to business-profile + a default bank
account. Carries the bank/VAT legal disclaimer.
- Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/
user/pass/from → saved to email_configs.
Each section persists only if started, and 'Skip for now' is always
available — soft settings keep their seeded defaults. en + de strings.
Removes the redundant standalone .picpeak card. The wizard's 'Upload
Backup' source now splits into two kinds: '.picpeak backup' (the working
portable restore — renders the upload + destructive-confirm flow inline)
and 'Manifest + files' (legacy, still 'Manifest Upload functionality
coming soon'). en + de strings added.
Downloading a portable backup is a "make a backup" action, so it belongs
next to "Run Backup Now" on the Dashboard, not under Restore. Split the
combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard
(Restore). The manifest stays bundled inside the .picpeak, so there is no
separate manifest-only download for the portable format.
Adds a self-contained "Portable backup (.picpeak)" card to the Restore
tab, completing the GUI-only roundtrip:
- Download: optional "include original photos" toggle + a prominent
plaintext-secrets warning, streams the file via a blob download.
- Restore: file picker → destructive confirmation modal ("replaces ALL
data except your current account, cannot be undone") → multipart upload
to /admin/backup/picpeak/import → success summary. If the backup uses
external media, shows a banner to reconfigure the mount, with a docs link.
Kept separate from the legacy RestoreWizard (different format/flow). en+de
strings added; dark-mode variants throughout.
Blocker: the "every file rejected" reset never fired because the backend
returns `upload_id` unconditionally (with count 0), so `anyQueued` was
always true and the completion effect (gated on total > 0) never ran —
modal spun forever. Gate `anyQueued` on `count > 0` so a zero-photo
response takes the terminal reset path.
Concern 1: processing-stage failures were invisible — the modal
auto-closed on clean transfer before the worker reported them. Defer the
settle/close decision to the completion effect (combining transfer +
processing failures), and persist failed photos into `processingFailures`
state before `uploadIds` is cleared, so the rows don't vanish the instant
they appear.
Also: report card gets role="status"/aria-live (nit), and tests now cover
the whole-chunk transfer failure, the clean-settle path, and the
onUploadSettled contract from the real component.
- Persist the layout choice in the toggle click handlers instead of a
useEffect, so simply opening the Photos tab no longer re-writes the
value it just read from localStorage (review concern 1).
- Give the Grid/List toggle radiogroup/radio + aria-checked semantics
so a screen reader announces them as one mutually-exclusive set
(review concern 2).
- Add a test that mount performs no localStorage write.
The failure report lives inside the upload modal, but the modal
auto-closed the instant the transfer finished (handleUploadComplete →
onClose), unmounting the report before the user could read it — so the
"which files failed" list never actually appeared.
Split the modal's completion callback in two:
- onUploadComplete: refresh the grid only (no close), as bytes land and
again when processing finishes
- onUploadSettled({ hasFailures }): fired once the transfer settles; the
modal auto-closes only on a clean upload and stays open (report
visible) when any file failed
Also reset the transfer UI when nothing was queued (every file failed),
which previously left the modal spinning forever. Add a PhotoUploadModal
test covering close-on-clean vs stay-open-on-failure.
A partial upload only told the admin "some files failed" with no way to
find out which ones — even though the data existed. The backend already
returns per-file rejections (response.errors: [{filename, error}]) and the
progress hook already exposes failedPhotos, but both were dropped.
Add a dismissible failure report to the upload modal listing every file
that didn't make it into the gallery, grouped by stage with its reason:
- rejected: per-file validation rejections from the upload response
(previously discarded entirely)
- transfer: whole-chunk request failures (now captured with the error,
not just the filename)
- processing: background-worker failures from useUploadProgress.failedPhotos
Replace the count-only "some files failed" toast with one that points at
the list. Add en/de keys under upload.failures.* and a component test
covering the rejected + processing rows and dismissal.
The event detail Photos tab (AdminPhotoGrid) only offered a thumbnail
grid. Add a Grid/List toggle in the action bar so admins can scan
photos in a compact, metadata-oriented list.
- New utils/photoViewPrefs.ts persists the choice per admin via
localStorage (mirrors utils/calendarPrefs.ts), defaulting to grid
- List view is a compact <table> following the established admin
list pattern (EventsListPage), with responsive column hiding:
Photo (thumbnail + filename + original + Video/Hidden badges),
Category (lg+), Uploaded date (md+, via useLocalizedDate),
Engagement views/downloads/likes (xl+), Feedback rating/comments
(sm+), Size, and hover Actions (download, delete)
- Rows reuse the existing selection, download, delete and category
handlers; row click opens the photo viewer
- Toggle buttons use LayoutGrid / List icons with aria-pressed state
- Add en.json + de.json keys under admin.photos (viewMode, gridView,
listView, columns.*)
- Tests for the persistence util and the toggle's render + persistence
Issue 3 from #699 (@alexvaltchev's report): expose a custom-named short
URL per event that bots scrape for OG previews and browsers redirect to
the underlying gallery. WhatsApp / iMessage / Facebook cache the OG
metadata by the URL they crawl, so the SHORT URL becomes the cache key
— admins can rotate or split-test underlying gallery URLs without
re-pushing a fresh link to clients.
Additive feature; no existing route, table, or column is modified.
## Backend
- `gallery_short_urls` table (migration 150): id, short_slug UNIQUE,
event_id FK CASCADE, target_path TEXT, created_by/at, hit_count,
last_hit_at, deleted_at/by. hasTable-guarded so the migration is
idempotent on re-run.
- `src/services/galleryShortUrlService.js` — validator + CRUD +
resolver. Slug rules: `/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/`,
reserved blocklist (admin, api, auth, gallery, og, s, login, ...).
target_path snapshots at create-time from the event + global
short-URL toggle, so a later flip of the toggle does NOT silently
change where existing short URLs resolve.
- `src/routes/adminShortUrls.js` — `GET/POST
/api/admin/events/:eventId/short-urls`, `DELETE
/api/admin/short-urls/:id`. Structured errors: 400 INVALID_SLUG,
409 SLUG_TAKEN (with `suggested`), 404 EVENT_NOT_FOUND. Gated by
events.view / events.edit + requireEventOwnership.
- `server.js` /s/:shortSlug public route. Bot UA → server-render the
same OG metadata the existing /og/gallery/<slug> handler produces,
then override og:url to point at /s/<shortSlug> itself (cache-key
invariant — social platforms key by the URL they scrape).
Browser UA → 302 to target_path. Soft-deleted slug → 410 Gone
(intentional-delete signal, distinct from 404 unknown slug).
Hit accounting is fire-and-forget.
## Frontend
- `services/shortUrls.service.ts` — list/create/remove.
- `components/admin/ShortUrlsCard.tsx` — per-event card on the
EventDetailsPage. Form for custom or auto-generated slug, list with
copy-to-clipboard + soft-delete. SLUG_TAKEN error surfaces the
service's `suggested` slug with a "use suggested" button.
- i18n: events.shortUrls.* added to EN + DE.
## Tests
78 new tests, all passing:
- `__tests__/utils/galleryShortUrlValidation.test.js` (48) — pure-
function tests for validateSlug: accepts/rejects, reserved-slug
blocklist, path-traversal + URL-injection vectors.
- `__tests__/integration/galleryShortUrls.test.js` (19) — service
layer against a real SQLite DB. Covers custom + auto-generated
slugs, collision + SLUG_TAKEN + suggested, target_path
snapshotting (backward-compat invariant), soft-delete + slug
rotation, hit counting.
- `__tests__/integration/galleryShortUrlRoute.test.js` (11) —
HTTP-level: 302 redirect for browser UA, 200 + OG HTML for bot UA,
og:url canonical points at /s/<slug>, 410 for soft-deleted +
orphaned events, 404 unknown + malformed.
Regression sweep: 47 existing migration-chain integration tests still
pass; migration 150 is additive only.
## Backward compatibility
- Existing `/gallery/<slug>`, `/gallery/<32-hex-share-token>`,
`/gallery/<slug>/show/<token>`, `/og/gallery/<slug>`,
`/og/gallery/<slug>/cover` routes are untouched.
- The `/s/` namespace is new; no existing route lives there.
- Migration 150 only ADDs the new table — no ALTERs on existing
schema, no destructive changes.
- target_path is snapshotted at create-time so flipping the global
"Use short gallery URLs" setting after a short URL exists does NOT
change where that short URL resolves.
Surfaces release highlights to admins, sourced from the GitHub release notes
(no AI at runtime). Bullets are written once per release in CI via GitHub Models
(see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app
reads that block and falls back to the changelog's "### Features" for releases
without it — so it works against today's releases immediately.
- backend utils/whatsNew.parseWhatsNew(body): curated block else Features
section, strips scope/PR-links, de-dups, caps at 8 (tested).
- GET /admin/system/updates/whatsnew: highlights for every version moved
through since the per-instance marker (whatsnew_last_seen_version); fresh
installs self-anchor silently. Best-effort, never errors.
- POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance).
- /admin/system/updates also returns latestHighlights for the teaser.
- Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on
the dashboard via adminService; UpdateNotification shows a "New features
include:" teaser. i18n de/en. No migration (uses app_settings).
One-time banner shown at the top of the admin layout to surface the org
rename + GHCR registry change for operators who haven't read the release
notes. Sits right below the existing maintenance banner — same pattern.
## What it looks like
Blue, dismissible banner with a short body:
> PicPeak's image registry has moved
> Update your docker-compose.yml to pull from
> ghcr.io/picpeak/picpeak/{backend,frontend} — the old path is no longer
> being updated. [See migration notes]
The link goes to `docs/migration-to-org.md` on the new org repo.
## Design choices
- **No backend feature flag.** A hard-coded `MIGRATION_BANNER_ENABLED`
constant in the component file (1 line) gates global display. After
~1 quarter, flip it to false (or drop the mount in `AdminLayout.tsx`)
in a small follow-up PR. A backend `app_settings` row + Settings UI
toggle would be overkill for a one-time migration event.
- **Per-admin dismissal via localStorage.** Key is `picpeak:migration-banner:v1`
(versioned so a future "we've moved AGAIN" banner can show without
inheriting earlier dismissal). Wrapped in try/catch so private-mode
browsers + storage-quota-exceeded errors don't crash the layout.
- **EN + DE strings** under a new top-level `migrationBanner` namespace.
Other locales (fr, nl, pt, ru) fall through to EN — `migrationBanner.*`
keys aren't translated there yet, deliberate (per #669 the ops
banner is operator-facing and admins reading EN/DE is the majority).
- **Reuses `common.dismiss`** for the close-button aria-label.
## Test plan
- [ ] Frontend `npm run build:check` passes (TS + build)
- [ ] Open admin dashboard in EN → banner shows at top, below header,
above main content
- [ ] Switch to DE → banner shows German strings
- [ ] Click dismiss → banner hides, doesn't re-appear on hard refresh
- [ ] Clear localStorage `picpeak:migration-banner:v1` → banner returns
- [ ] Flip `MIGRATION_BANNER_ENABLED` to false → banner doesn't render
for anyone, regardless of dismissal state
Refs #669.
Repo transferred from the-luap/picpeak → PicPeak/picpeak. Docker images
publish to ghcr.io/picpeak/picpeak/{backend,frontend} (lowercase, per the
GHCR canonical form computed by docker-build.yml's `${GITHUB_REPOSITORY,,}`).
Sweep covers:
- docker-compose.production.yml + Dockerfiles → new image registry path
- README, CONTRIBUTING, SECURITY, SIMPLE_SETUP, scripts/picpeak-setup.sh
→ new GitHub URLs
- Update-check / release-notes services (updateCheckService,
environmentService, updateNotificationService, adminSystem,
UpdateNotification, githubReleaseUrl) → GitHub API + tag URLs use the
canonical PicPeak/picpeak path
- Issue templates + README-DOCKER + workflow README → updated package URLs
- One commit-context comment in migrations/090 + customerAccountsService
CHANGELOG.md is intentionally untouched (historical release entries are
immutable; GitHub auto-redirects the old URLs indefinitely).
CLAUDE.md keeps the bare `(the-luap)` reference — that's the maintainer's
personal handle, not a repo URL.
22 files, 48/48 line swaps (every change is a 1:1 URL replacement).
The earlier change only relabeled is_monthly_draft rows. But a per-event
invoice created from hours is status 'scheduled' with scheduled_send_at = NULL
and is_monthly_draft = false — it never auto-ships (the scheduler only picks
rows with scheduled_send_at <= now), yet it still read "Scheduled" on the
customer panel + lists.
Add a shared isDraftInvoice() helper (scheduled && no send date, or a
monthly/manual accumulator) and use it for the badge in the Bills list, the
invoice detail header, and the customer profile's invoice panel. A scheduled
invoice WITH a future send date keeps "Scheduled".
Per request, keep the dashboard to four tiles rather than adding a fifth: the
"Revenue · last 365 days" tile is now clickable and toggles in place between
the trailing-365-day window and calendar year-to-date (since Jan 1).
- adminDashboard: new calendar-year cutoff + revenue.calendarYearMinor (same
cash-basis paid_at window logic as the existing trio).
- StatCard gains an optional onClick (renders as a button); the year tile uses
it, with a "Tap to switch window" hint for discoverability.
- bills.service CrmOverviewStats.revenue gains calendarYearMinor.
Manual/monthly-cadence customers accumulate logged hours into one running
draft invoice (is_monthly_draft, migration 128). That draft gets a real
invoice number and stamps the hours ("Billed: R-2026-0026"), but listInvoices
hid is_monthly_draft rows from the main list — so the invoice looked lost even
though it existed on the customer's monthly-queue card. It also carried status
'scheduled' despite never auto-sending on manual cadence, reading misleadingly
as "Scheduled".
- Bills list now opts into drafts via a new `includeDrafts` query param
(GET /admin/invoices → listInvoices includeMonthlyDrafts). Pickers/sub-lists
that reuse billsService.list leave it off, so they're unaffected.
- Draft rows render a distinct "Draft" badge instead of "Scheduled"
(transformInvoice already exposes isMonthlyDraft).
- The hours "Billed: R-…" chip now links straight to its invoice.
- i18n: bills.status.draft (de "Entwurf", en "Draft").
The live totals panel in the quote/invoice editor (LineItemsTable) summed the
per-line rounded totals and showed that as Total — so with crm_invoice_round_total
on, a 4 × (2.5h @ 32.25) invoice previewed CHF 322.52 while the saved invoice +
PDF correctly show 322.50 with a Rundung row. The preview now mirrors the backend.
- LineItemsTable gains a `roundTotal` prop. When set, it computes the clean net
(full-precision sum rounded once — same rule as backend
utils/invoiceRounding.cleanNetMinor, including the migration-119 priced
sub-item override), shows a "Rundung" row for the drift, and folds it into the
VAT base + Total. Off ⇒ unchanged (no row).
- Bill + Quote editors pass roundTotal from appSettings.crm_invoice_round_total.
- i18n: crm.lineItems.rounding (de "Rundung", en "Rounding").
The saved-invoice detail view already shows the stored clean total, so no change
there.
handleSubmit required a password whenever requirePassword was true, but the
password field only renders on the inline-email path (requirePassword &&
customerEmail). For a password-protected gallery with no inline email the field
was hidden, so submit blocked on the missing password and the dialog never
closed. Gate password collection + validation on a single `needsPassword`
(requirePassword && customerEmail); the no-inline-email path publishes without
re-entering the password (existing hash kept, customer reaches it via portal).
Publishing a gallery with no inline customer_email but assigned customer
account(s) previously sent nothing (the dialog said "no notification"). Now the
publish route falls back to the existing customer_gallery_assigned "your
galleries" email (sent per assigned active account in their preferred language)
so registered customers learn the gallery is available. Inline-email path
(gallery_created) is unchanged.
The publish dialog now reflects this: with an inline email it notifies that
address; with only assigned accounts it says the account(s) will be notified;
with neither, the button is just "Publish" (no false notify promise). Exports
notifyCustomerOfNewAssignments; EN/DE strings added.
Reporter @the-luap hit the German `Veröffentlichen & Kunden benachrichtigen`
button overflowing the modal footer in the publish dialog. Two failure modes
chained:
1. The footer was a `flex` row with two `flex-1` buttons inside a
`max-w-md` (448 px) modal. Default `min-width: auto` on flex children
meant the primary button kept its content width (~340 px including the
paper-airplane icon + padding) and pushed the row past the modal frame.
2. Adding `min-w-0 whitespace-normal` doesn't help — the base `.btn` class
has `@apply ... whitespace-nowrap` (`index.css:149`) which wins over a
utility className via the Tailwind CSS cascade order. So the text won't
wrap, the button silently extends past the modal frame, no overflow
indicator. Verified with `getComputedStyle().whiteSpace = 'normal'` and
the button still rendering as one ~340 px wide line at ~224 px allocated
space.
Fix: stack both buttons vertically (`flex flex-col-reverse gap-3`). Primary
appears on top visually (col-reverse), cancel below — standard
confirmation-dialog pattern (Material, Headless UI, Radix all do this for
single-action dialogs). Works in every locale and viewport regardless of
label length. No side-by-side row to overflow.
Tried two prior shapes that didn't hold:
- `flex-col-reverse sm:flex-row` with `sm:flex-1 min-w-0 whitespace-normal`
on the primary: still overflowed silently because of the
whitespace-nowrap cascade above.
- `flex-col-reverse sm:flex-row sm:justify-end` with content-width buttons:
`justify-end` doesn't constrain a row whose content sum exceeds the
container; row just pushes left of the modal.
Bumping the modal to `max-w-lg` (or wider) was also considered and rejected:
matching modal width is asymmetric (every other admin dialog stays at
`max-w-md`), and any locale longer than German would re-hit the wall.
Stack-always is the only shape that handles every locale + every viewport
without per-language tuning.
Verified end-to-end against a dockerised dev backend:
- DE + EN × desktop (1280px) + mobile (375px) — all four show primary on
top, cancel below, both inside the modal frame, no overflow.
Lint + tsc + full vitest suite (84/84) clean.
Closes#670.
Adds the admin Workflows surface (top-level nav, gated by the workflows
flag + workflows.view): a list page (enable toggle, delete, new), a
pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react)
canvas editor — palette to add nodes, drag handle→handle to connect
(branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a
side-panel JSON config editor, and save (writes a new version). Routes +
sidebar entry + workflows.service. Build + tsc clean.
NOTE: the workflow page strings render via inline English fallbacks; DE
translations for the workflows.* block are still pending native review.
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.
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.
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.
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.
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.
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.
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.)
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.
- 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.
Ports 8digit/picpeak@88bfde1 — replaces `window.confirm()` with a styled,
themed, accessible in-app modal. Usage:
const confirm = useConfirm();
const ok = await confirm({
title: 'Delete event?',
message: 'This will permanently remove the gallery and all photos.',
variant: 'danger',
confirmLabel: 'Delete',
});
if (ok) doDelete();
Three variants: 'primary' (default, no icon), 'danger' (red AlertCircle +
red confirm button), 'warning' (amber AlertTriangle). Keyboard support:
Escape cancels, Enter confirms (unless focus is in an input/textarea/select
so an open form doesn't get hijacked), backdrop click cancels. Cancel button
is focused by default — a stray Enter cannot accidentally confirm a
destructive action.
Wraps at App.tsx level, inside GlobalThemeProvider so the modal respects
the theme tokens, above the toast container so a confirm appearing under a
toast still gets the click. Provider exports through components/common
alongside the rest of the shared primitives.
This PR only lands the primitive. Existing window.confirm() call-sites are
left untouched — sweeping them is follow-up work that can land in any
cadence (each sweep is one component, no architectural risk). Existing
structured-input flows (PublishGalleryDialog, DuplicateEventDialog,
PasswordResetModal, etc.) stay as-is — they collect data, not yes/no.
No new i18n entries — uses common.cancel / common.confirm / common.close
which already exist in EN + DE.
### Test plan
- [x] tsc --noEmit clean
- [x] eslint clean on changed files
- [ ] Manual: pick any existing window.confirm() site (e.g. EventDetailsPage
delete button), swap to useConfirm(), verify the modal renders with
theme tokens, Escape cancels, Enter confirms, backdrop click cancels,
focus lands on Cancel
- [ ] Manual: variant='danger' renders red confirm button + AlertCircle icon
- [ ] Manual: open the dialog from inside another modal (e.g. a settings
panel) — z-[9999] keeps the confirm on top of any other overlay
Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.
Defaults to true so categories created before migration 135 keep working
without admin intervention.
Credit: 8digit/picpeak@928164b + @751ec75.
### Backend
- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
- `GET /:slug/photos` returns `allow_downloads` per category AND
`category_allow_downloads` per photo.
- `GET /:slug/download/:photoId` returns 403 when the photo's category
disables downloads.
- `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
`whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
The null check covers pre-migration-135 rows during the upgrade window.
- `POST /:slug/download-selected` same filter pattern.
### Frontend
- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
Green DownloadCloud icon when downloads are on, plain Download icon when
off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.
No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.
### Test plan
- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
(filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
→ downloads still work (defaults true via fallback)
LineItemsTable's live preview did `Math.round(subtotal * vatRate) / 100` where
subtotal is in major units and vatRate is a fraction (0.081) — rounding to whole
units before the /100 divided the VAT by 100 (CHF 0.63 instead of 63.18). Add the
missing *100 inside the round so it rounds to cents. Backend computeTotals + the
PDF + the tax report were always correct; this preview-only bug just surfaced now
that new invoices seed a non-zero default VAT code instead of 0%.
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
longer auto-classifies every supplier — incl. the admin's own domestic one —
as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
so they aren't billable to an anonymized account. (NB: picpeak has no hard
customer delete — erase anonymizes in place — so the orphan/404 premise can't
occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
(the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
/hourly + profileFields.title/savedToast).
- Box 1 "Default rates": mileage, daily allowance, hourly rate, require-proof.
Hints now make the cost-vs-billing split explicit (daily allowance = expense,
hourly = billing fallback).
- Box 2 retitled "VAT": registration, reclaim, default invoice VAT code, and
the VAT label (moved out of its own card).
- Drop the third card (AccountingProfileFields deleted); the two Save buttons
become one — it persists both the app_settings and the two business_profile
fields (VAT label + hourly rate) together.
- Rename "Per-diem" → "Daily allowance" (EN) for clarity; German keeps the
established "Spesenpauschale".
- Remove the orphaned "Default VAT rate %" from Business profile; the rates
are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
is now code-only — options are exactly the Accounting output codes, no
free-text custom rate. Off-list legacy values on existing invoices are
preserved as a read-only "(not configured)" option so issued documents
aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
AccountingProfileFields card; storage stays on business_profile, own save).
Wire vat_label onto the PDF VAT-line label via the issuer block (covers
invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
preserved). Add a moved-note callout. Strip the moved fields from the
Business-profile save so it can't clobber an Accounting-tab edit.