Compare commits

...

41 Commits

Author SHA1 Message Date
Paul Nothaft f0cdcddb92 chore(main): release 3.88.0-beta.0 (#805)
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
2026-07-15 21:07:21 +00:00
Paul Nothaft 0751a08aa6 Merge pull request #804 from PicPeak/fix/gallery-feedback-filter-chips
fix(gallery): show feedback filter chips on desktop for galleries without categories
2026-07-15 23:02:48 +02:00
Paul Nothaft d64eef8abf Merge pull request #803 from PicPeak/fix/event-type-hardcoded-deps
fix(event-types): un-hardcode event type dependencies in v1 API and CRM
2026-07-15 23:02:35 +02:00
Paul Nothaft 109aba8598 Merge pull request #801 from PicPeak/feat/setup-wizard-event-types
feat(setup): event-types step in first-run wizard + un-hardcode event type dependencies
2026-07-15 23:01:36 +02:00
Paul Nothaft b9283386a5 fix(gallery): show feedback filter chips on desktop for galleries without categories (#802)
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.

Render the row whenever either part has content and gate only the
category scroller on categories existing. The media-count label hides
below lg when no categories exist so the mobile layout stays unchanged
(mobile keeps its own chip block). With-categories galleries render
identically to before.

Regression test pins both chip groups in the DOM with and without
categories (fails on the pre-fix component).
2026-07-15 22:51:22 +02:00
Paul Nothaft 5da1c3a12f fix(event-types): un-hardcode event type dependencies in v1 API and CRM (#800)
Split out of #801 so the public-API behavior change gets its own review:

- v1 POST /events validates event_type against the live event_types
  catalog instead of the hardcoded whitelist — custom types created in
  Settings → Event Types were rejected with 400. BREAKING for the
  never-seeded 'family' slug, which the old whitelist silently accepted
  and wrote as a dangling reference; create a matching event type to
  keep using it
- new GET /api/v1/event-types (read scope) so API-token clients can
  discover valid slugs; OpenAPI enum replaced accordingly
- standalone contract→event conversion no longer hardcodes
  event_type: 'wedding' — it resolves via crm_default_event_type, then
  the catalog catch-all, same chain as quote→event conversion
- resolveDefaultEventType moved from quoteService to eventTypeService
  for shared use (no behavior change)
2026-07-15 22:31:28 +02:00
Paul Nothaft 93301002ba refactor: move v1 API + CRM event-type un-hardcoding to a follow-up PR
Keeps #801 scoped to the setup-wizard event-types feature and its
load-bearing guards. The v1 validator/discovery endpoint and the
contract-conversion default fix ship separately so the public-API
behavior change gets its own review weight.
2026-07-15 22:30:00 +02:00
Paul Nothaft f8ba669716 fix(event-types): harden setup window + catalog validation (codex review)
Three review rounds on PR #801; fixes in response:

- isValidEventType: live catalog is authoritative when it has rows — a
  deleted or deactivated slug no longer validates via the legacy
  fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
  updateEventType: refuse deactivating the last active type (unknown
  slugs are rejected since the validator change, so an empty active
  catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
  (a portable-backup restore can leave the key absent) and a normal
  admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
  stripped from ALL generic settings upserts (/general, /security,
  /analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
  and a genuinely failed system-type deletion reloads the list and
  stays on the step instead of advancing past the only window in which
  it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
  the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
  discover valid slugs; OpenAPI enum replaced with the live-catalog
  description
2026-07-15 22:21:20 +02:00
Paul Nothaft 00fff24a1c test(v1): stub eventTypeService in events.create suite + cover unknown-type 400
The catalog-backed event_type validator (#800) makes a db('event_types')
lookup before the handler runs, which consumed the first queued mock
chain and shifted the pinned db() call sequence — 5 tests failed on CI.
Stub isValidEventType to true (validation isn't this suite's subject)
and add an explicit test for the new 400-on-unknown-type path.
2026-07-15 21:36:50 +02:00
Paul Nothaft 7eb6357b4a feat(setup): event-types step in first-run wizard + un-hardcode event type deps (#800)
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.

- New wizard step between features and config: edit name/URL prefix,
  remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
  an admin already exists, false on fresh installs; POST /api/setup/
  complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
  in-use check extended to quotes; per-type reminder template
  (event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
  removed from the catalog
- v1 API event creation validates event_type against the live catalog
  instead of a hardcoded whitelist (custom types were rejected; the
  never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
  crm_default_event_type / resolveDefaultEventType instead of
  hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
  to eventTypeService for reuse)
2026-07-15 21:30:23 +02:00
Paul Nothaft aab9e1a937 chore(main): release 3.87.0-beta.0 (#797)
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
2026-07-11 20:24:23 +00:00
Paul Nothaft ffd4a7eee6 Merge pull request #796 from Luca-Timo/feat/invoice-vat-note
feat(invoices): configurable VAT note under MwSt. line + fix multi-page page-number overlap (#794)
2026-07-11 22:21:41 +02:00
Luca 1476884dd0 feat(invoices): configurable VAT/free-text note + fix multi-page page-number overlap (#794)
Two invoice-PDF changes from #794.

1. VAT / free-text note (Benedikt's request, placement A). A new
   `crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
   free-text line directly under the MwSt. row on every invoice. Data-driven:
   the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
   UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
   totals-block reserve grows by the measured note height so a long note can't
   push the grand total into the footer. Read in invoice/render.js, threaded
   through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
   unaffected.

2. Multi-page footer overlap. On a full continuation page the line-item table
   filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
   marginBottom-12 — INSIDE that fill zone — so items overlapped the page
   number. Move the stamp into the bottom margin (below the content edge),
   zeroing that page's bottom margin during the write so it can't trigger
   PDFKit's auto-page-break. Verified: on a full page the lowest item text is
   at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.

Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
2026-07-11 02:01:20 +02:00
Paul Nothaft e3d597b89a Merge pull request #787 from PicPeak/ci/push-images-to-dockerhub
ci(docker): also publish images to Docker Hub (picpeak/backend, picpeak/frontend)
2026-07-10 20:35:58 +02:00
Paul Nothaft ea9caa9c5d chore(main): release 3.86.0-beta.0 (#795)
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
2026-07-10 18:30:28 +00:00
Paul Nothaft d51112e761 Merge pull request #790 from Luca-Timo/feat/category-reorder
feat(categories): per-event category ordering — global default + override (#782)
2026-07-10 20:26:11 +02:00
Luca a4b4485d32 fix(categories): address PR #790 review — event ownership, migration renumber, nits
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
  requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
  inline check (super_admin bypasses; others limited to owned/ownerless events).
  New test covers a settings.edit-holding non-super_admin blocked (403) on all
  three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
  headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
  en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.
2026-07-10 20:10:40 +02:00
Luca 8d0a946478 test(categories): integration tests for layered category ordering (#782)
Real-DB coverage: migration 158 backfill; global default reorder + a
non-customised event following it; per-event override + isolation from other
events; override accepts globals / rejects a foreign event's category; reset
clears the override; create appends.
2026-07-10 16:24:17 +02:00
Luca 4698402b54 feat(categories): per-event category ordering — global default + override (#782)
Order a gallery's categories in the flow of the day instead of A–Z. Two layers,
resolved per event: per-event override > global default > name.

- migration 158: photo_categories.display_order (global default), backfilled
  from the current alphabetical order so existing galleries don't reshuffle.
- migration 159: event_category_order (event_id, category_id, position) — the
  per-event override; no backfill, every event starts on the default.
- utils/categoryOrder: shared resolution used by the admin event view and the
  public gallery; fails safe to the global default if the table is absent.
- adminCategories: POST /reorder sets a per-event override (globals +
  event-specific, interleaved); DELETE /reorder/:eventId resets; POST
  /reorder-global sets the global default. Ordering endpoints + create append.
- gallery renders the resolved order.
- Settings → Photo Categories reorders the global default; an event's Categories
  tab reorders that gallery (one combined list + Reset to default). Up/down
  buttons — no drag-and-drop dependency.
- en/de strings.
2026-07-10 16:24:17 +02:00
Paul Nothaft ed0fa3241b chore(main): release 3.85.0-beta.0 (#793)
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
2026-07-10 13:38:57 +00:00
Paul Nothaft 54676424f2 Merge pull request #788 from PicPeak/feat/slideshow-order-category
feat(slideshow): per-event play order + category filter (#202)
2026-07-10 15:35:45 +02:00
Paul Nothaft b41cb1586d chore(main): release 3.84.1-beta.0 (#792)
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
2026-07-10 12:21:09 +00:00
Paul Nothaft 1f3bc3c343 Merge pull request #791 from PicPeak/fix/docker-v-tag-via-ref
fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
2026-07-10 14:17:11 +02:00
Paul Nothaft 39db7bf6cb fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).

Replace the v{{version}}/v{{major}} semver patterns with type=ref,event=tag,
which emits the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) for both
stable and beta tags — exactly the string users pin (matches the GitHub
release). Applies to both backend + frontend merge metadata steps.

Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
2026-07-10 14:13:17 +02:00
Paul Nothaft aeade94a35 Merge pull request #786 from PicPeak/release-please--branches--main
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(main): release 3.84.0-beta.0
2026-07-10 13:04:13 +02:00
Paul Nothaft b768a53c5b feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:

- **Play order** (show_order): 'chronological' (upload order, default) or
  'random' — the client shuffles the initial set (Fisher-Yates) so
  live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
  single photo category (NULL = all photos, default). Enforced
  server-side on the slideshow /photos access and mirrored in the
  /session + /state photo_count, so the kiosk viewer can't widen the set.

Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).

- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
  (picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
  photos + matching count; order propagates) and the running kiosk
  requests exactly the filtered set; tsc clean, 136 backend tests pass.
2026-07-10 10:46:54 +02:00
Luca 1f19fbb1b2 ci(docker): mirror published images to Docker Hub
Add picpeak/backend + picpeak/frontend on Docker Hub alongside GHCR. The
merge jobs already assemble the multi-arch manifest from the per-arch GHCR
digests via 'imagetools create'; adding Docker Hub to metadata-action's
images list + a Docker Hub login makes the same command push the manifest to
both registries (blobs copied from GHCR). No change to the build-by-digest
jobs.

Full tag parity (main, stable, latest, semver, sha). Gated on
DOCKERHUB_ENABLED (github.repository == PicPeak/picpeak) so forks stay
GHCR-only and keep building. Requires repo secrets DOCKERHUB_USERNAME and
DOCKERHUB_TOKEN.
2026-07-10 10:26:06 +02:00
Paul Nothaft 03ded870bf chore(main): release 3.84.0-beta.0 2026-07-10 10:20:17 +02:00
Paul Nothaft df5aeaba41 Merge pull request #785 from PicPeak/docs/releasing-stable-version-alignment
docs(releasing): align stable version to main on promote (Option A)
2026-07-10 10:20:04 +02:00
Paul Nothaft 279e0472c7 Merge pull request #784 from PicPeak/feat/admin-github-repo-button
feat(admin): GitHub repo button in the sidebar footer (#778)
2026-07-10 10:19:49 +02:00
Paul Nothaft 2ee4146d9a Merge pull request #783 from PicPeak/fix/docker-versioned-tags-v-prefix
fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
2026-07-10 10:19:22 +02:00
Paul Nothaft 5dea0c9695 docs(releasing): align stable version to main on promote (Option A)
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.

Also records the release-engineering note that release-please.yml must
keep target-branch: stable (the missing pin cut a bogus v2.7.0 once).
2026-07-10 10:03:30 +02:00
Paul Nothaft d3d7df46f2 feat(admin): GitHub repo button in the sidebar footer (#778)
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.

- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
  (githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
  (`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
2026-07-10 09:50:05 +02:00
Paul Nothaft 784d059c3d fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
docker/metadata-action's type=semver strips the leading 'v', so releases
published only :3.45.0 / :3.83.1-beta.0. But git tags + GitHub releases
are named v3.45.0, so anyone pinning ghcr.io/.../backend:v3.45.0 (the
obvious choice) hit 'manifest unknown' — exactly #664.

Add v-prefixed semver patterns (v{{version}}, v{{major}}.{{minor}},
v{{major}}) alongside the existing bare ones, for both backend and
frontend. Now both :v3.45.0 and :3.45.0 resolve.

Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
2026-07-10 09:46:10 +02:00
Paul Nothaft dbe4b588eb chore(main): release 3.83.1-beta.0 (#776)
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
2026-07-09 11:17:23 +00:00
Paul Nothaft 274ef0cd73 Merge pull request #774 from PicPeak/fix/release-please-stable-target
fix(release): target stable in release-please + undo bogus 2.7.0 bump
2026-07-09 13:13:23 +02:00
Paul Nothaft 65ac6eddac fix(release): target stable in release-please.yml + undo the bogus 2.7.0 bump
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.

- release-please.yml: add `target-branch: stable` so it releases from
  the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
  already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
  package.json), set `.release-please-manifest.json` to 3.44.0, and drop
  the bogus 2.7.0 CHANGELOG section.

The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
2026-07-09 11:39:16 +02:00
Paul Nothaft be710eb1de Merge pull request #773 from PicPeak/release-please--branches--main
chore(main): release 2.7.0
2026-07-08 21:14:07 +02:00
Paul Nothaft 58a86af868 chore(main): release 2.7.0 2026-07-08 20:46:56 +02:00
Paul Nothaft 1250306d11 Merge pull request #772 from PicPeak/ci/run-tests-on-stable
ci: run the Tests workflow on stable-targeted PRs
2026-07-08 20:42:01 +02:00
Paul Nothaft 80503c52b9 ci: run the Tests workflow on stable-targeted PRs
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
2026-07-08 20:29:19 +02:00
54 changed files with 2304 additions and 263 deletions
+97 -4
View File
@@ -95,6 +95,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -233,6 +242,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -266,11 +284,24 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
# GHCR always; Docker Hub (picpeak/backend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/backend' || '' }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
@@ -282,6 +313,10 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -298,10 +333,15 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }}
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
@@ -331,6 +371,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Prepare platform pair
run: |
@@ -450,6 +499,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Download digest artifacts
uses: actions/download-artifact@v4
@@ -483,11 +541,24 @@ jobs:
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
if: env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
# GHCR always; Docker Hub (picpeak/frontend) added on the canonical repo so
# the same tag scheme is mirrored to both registries. metadata-action drops
# the blank second line on forks → GHCR-only there.
images: |
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/frontend' || '' }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
@@ -499,6 +570,10 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
# so users can pin the same string as the GitHub release. metadata-action's
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
type=ref,event=tag
type=sha,format=short
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
@@ -515,10 +590,15 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
- name: Inspect manifest (GHCR)
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Inspect manifest (Docker Hub)
if: env.DOCKERHUB_ENABLED == 'true'
run: |
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
@@ -532,6 +612,15 @@ jobs:
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
# other owner) fall back to GHCR-only — the Docker Hub image line and login
# are gated on this flag so their builds keep working unchanged.
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
else
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
fi
- name: Build Summary
run: |
@@ -570,6 +659,10 @@ jobs:
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
+1
View File
@@ -25,6 +25,7 @@ jobs:
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
+2 -2
View File
@@ -17,9 +17,9 @@ name: Tests
on:
push:
branches: [main, beta]
branches: [main, beta, stable]
pull_request:
branches: [main, beta]
branches: [main, beta, stable]
workflow_dispatch:
permissions:
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.83.0-beta.0"
".": "3.88.0-beta.0"
}
+1 -3
View File
@@ -1,3 +1 @@
{
".": "2.6.1"
}
{".":"3.44.0"}
+81
View File
@@ -5,6 +5,87 @@ 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.88.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.87.0-beta.0...v3.88.0-beta.0) (2026-07-15)
### Features
* **setup:** event-types step in first-run wizard + un-hardcode event type dependencies ([109aba8](https://github.com/PicPeak/picpeak/commit/109aba859820bf80440d056baf183ecf2657fee3))
* **setup:** event-types step in first-run wizard + un-hardcode event type deps ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([7eb6357](https://github.com/PicPeak/picpeak/commit/7eb6357b4a9bf3914674a63afa386a5fcf8c2161))
### Bug Fixes
* **event-types:** harden setup window + catalog validation (codex review) ([f8ba669](https://github.com/PicPeak/picpeak/commit/f8ba6697163b4d9aa0fa0014cb5b0810371c04ae))
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([d64eef8](https://github.com/PicPeak/picpeak/commit/d64eef8abf2915230b3cdd38a3bbb8af1a12c6d2))
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([5da1c3a](https://github.com/PicPeak/picpeak/commit/5da1c3a12f603a230091426b1d7be0eac83da22c))
* **gallery:** show feedback filter chips on desktop for galleries without categories ([0751a08](https://github.com/PicPeak/picpeak/commit/0751a08aa661a430c1609cd8c118347291cbaa14))
* **gallery:** show feedback filter chips on desktop for galleries without categories ([#802](https://github.com/PicPeak/picpeak/issues/802)) ([b928338](https://github.com/PicPeak/picpeak/commit/b9283386a57431ac8bd395347f9acb9bbdf82e8e))
## [3.87.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.86.0-beta.0...v3.87.0-beta.0) (2026-07-11)
### Features
* **invoices:** configurable VAT note under MwSt. line + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([ffd4a7e](https://github.com/PicPeak/picpeak/commit/ffd4a7eee64b6418df1c9cc6843d86dc0f41d2ec))
* **invoices:** configurable VAT/free-text note + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([1476884](https://github.com/PicPeak/picpeak/commit/1476884dd04202f5f18d50d458b6176b0535c71b))
## [3.86.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.85.0-beta.0...v3.86.0-beta.0) (2026-07-10)
### Features
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([d51112e](https://github.com/PicPeak/picpeak/commit/d51112e761d2fd83f1939841fbf4c05e625fc34d))
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([4698402](https://github.com/PicPeak/picpeak/commit/4698402b5493cfbdb1e2b6d81c6f58829e17a703))
### Bug Fixes
* **categories:** address PR [#790](https://github.com/PicPeak/picpeak/issues/790) review — event ownership, migration renumber, nits ([a4b4485](https://github.com/PicPeak/picpeak/commit/a4b4485d322514690c5400ca7ab9a91bc25c3e48))
## [3.85.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.1-beta.0...v3.85.0-beta.0) (2026-07-10)
### Features
* **slideshow:** per-event play order + category filter ([#202](https://github.com/PicPeak/picpeak/issues/202)) ([5467642](https://github.com/PicPeak/picpeak/commit/54676424f2f7ed50e74cb8e144cbdaa5a96e65c3))
## [3.84.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.0-beta.0...v3.84.1-beta.0) (2026-07-10)
### Bug Fixes
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([1f3bc3c](https://github.com/PicPeak/picpeak/commit/1f3bc3c3430414b5b6cb2141d887a8b5855a04af))
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([39db7bf](https://github.com/PicPeak/picpeak/commit/39db7bf6cb5c39fcdf71c875a4aaf704f34447fa))
## [3.84.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.1-beta.0...v3.84.0-beta.0) (2026-07-10)
### Features
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([279e047](https://github.com/PicPeak/picpeak/commit/279e0472c71c6a37ba091a9c7a31f5571c0a8df6))
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([d3d7df4](https://github.com/PicPeak/picpeak/commit/d3d7df46f214028ba89063079d356bc0430083f5))
### Bug Fixes
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([2ee4146](https://github.com/PicPeak/picpeak/commit/2ee4146d9a6fd026e7b7be3ba774de9a0cf6e96a))
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([784d059](https://github.com/PicPeak/picpeak/commit/784d059c3da5b36e2b6794ebf2e34bc15c8a9824))
### Documentation
* **releasing:** align stable version to main on promote (Option A) ([df5aeab](https://github.com/PicPeak/picpeak/commit/df5aeaba416726cc0123f32ddf88e4a30dc28908))
* **releasing:** align stable version to main on promote (Option A) ([5dea0c9](https://github.com/PicPeak/picpeak/commit/5dea0c969558f50833973ff742257780f5842612))
## [3.83.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.0-beta.0...v3.83.1-beta.0) (2026-07-09)
### Bug Fixes
* **release:** target stable in release-please + undo bogus 2.7.0 bump ([274ef0c](https://github.com/PicPeak/picpeak/commit/274ef0cd731765b057a5d62d5f41c14cb3a1564b))
* **release:** target stable in release-please.yml + undo the bogus 2.7.0 bump ([65ac6ed](https://github.com/PicPeak/picpeak/commit/65ac6eddacb79857e9a9651d3c869e7bfdd92887))
## [3.83.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.6-beta.0...v3.83.0-beta.0) (2026-07-08)
+18 -4
View File
@@ -52,13 +52,19 @@ The actual mechanics, in order:
- **`.release-please-manifest.json`** — keep `stable`'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. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
```bash
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
```
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
6. **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).
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. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
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.
8. **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.
9. **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)
@@ -83,6 +89,14 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
### Stable ↔ pre-release number alignment
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
## 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.
@@ -0,0 +1,211 @@
/**
* Layered per-event category ordering (#782).
*
* Two ordering layers, resolved per event:
* - GLOBAL default — photo_categories.display_order (migration 159),
* set via POST /reorder-global; applies everywhere.
* - PER-EVENT override — event_category_order (migration 160), set via
* POST /reorder; overrides the default for one gallery.
* - DELETE /reorder/:eventId clears an event's override.
*
* Verified against a real SQLite DB with the full core-migration set applied.
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('category ordering (#782)', () => {
let db;
let cleanup;
let token;
let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
async function insertEvent(slug) {
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug, share_link: slug,
event_name: slug, event_date: '2026-01-01',
});
return (await db('events').where({ slug }).first()).id;
}
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
const res = await db('photo_categories').insert({
name,
slug: name.toLowerCase().replace(/\s+/g, '-'),
is_global: is_global ? 1 : 0,
event_id,
display_order,
}).returning('id');
return res[0]?.id ?? res[0];
}
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
describe('migration 159 backfill', () => {
it('seeds display_order from alphabetical order, scoped per event', async () => {
const eventId = await insertEvent('backfill-ev');
await insertCat('Reception', { event_id: eventId });
await insertCat('Ceremony', { event_id: eventId });
await insertCat('Pre-Ceremony', { event_id: eventId });
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
await require('../../migrations/core/159_add_category_display_order').up(db);
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
});
});
describe('global default order (POST /reorder-global)', () => {
it('reverses the global order and every non-customised event follows it', async () => {
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
expect(before.length).toBeGreaterThan(1);
const reversedIds = before.map((c) => c.id).reverse();
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
.send({ orderedIds: reversedIds })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
// A fresh event (no override) shows globals in the new global order.
const eventId = await insertEvent('follows-global');
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
expect(globalsInEvent).toEqual(reversedIds);
});
});
describe('per-event override (POST /reorder)', () => {
it('pins a custom order for one event without affecting another', async () => {
const eventA = await insertEvent('override-a');
const eventB = await insertEvent('override-b');
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
const a2 = await insertCat('A-Reception', { event_id: eventA });
// Current resolved list for A (globals + A's two categories).
const listA = (await getEvent(eventA)).body;
// Put A-Reception first, then A-Ceremony, then the globals in their order.
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
const desired = [a2, a1, ...globalsA];
const res = await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventA, orderedIds: desired })
.expect(200);
expect(res.body.map((c) => c.id)).toEqual(desired);
// override_position is set on every row for a customised event.
expect(res.body.every((c) => c.override_position != null)).toBe(true);
// Event B is untouched — no override, follows the global default.
const listB = (await getEvent(eventB)).body;
expect(listB.every((c) => c.override_position == null)).toBe(true);
});
it('accepts global ids but rejects another events category', async () => {
const eventId = await insertEvent('scope-ev');
const own = await insertCat('Own', { event_id: eventId });
const global = (await db('photo_categories').where('is_global', 1).first()).id;
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
// A global id is allowed (globals can be arranged per event).
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, global] })
.expect(200);
// A foreign event's category is out of scope.
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [own, foreign] })
.expect(400);
});
});
describe('reset (DELETE /reorder/:eventId)', () => {
it('clears the override and reverts to the global default', async () => {
const eventId = await insertEvent('reset-ev');
const c1 = await insertCat('R-One', { event_id: eventId });
const list = (await getEvent(eventId)).body;
const globals = list.filter((c) => c.is_global).map((c) => c.id);
await auth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
.expect(200);
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
expect(res.body.every((c) => c.override_position == null)).toBe(true);
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
});
});
describe('event ownership (PR #790 review)', () => {
let limitedToken;
let foreignEventId;
beforeAll(async () => {
const bcrypt = require('bcrypt');
// A non-super_admin role that DOES hold settings.view + settings.edit —
// the exact case the review flagged (settings.edit is grantable).
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
const roleId = roleRes[0]?.id ?? roleRes[0];
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
const a2 = await db('admin_users').insert({
username: 'limited', email: 'limited@example.com',
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
must_change_password: false, created_at: new Date(),
}).returning('id');
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
// An event owned by a DIFFERENT admin (the seeded super_admin).
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
await db('events').insert({
event_type: 'wedding', password_hash: 'x',
expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
});
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
});
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
});
});
describe('POST / (create) appends to the end of its scope', () => {
it('assigns display_order = max + 1 within the event', async () => {
const eventId = await insertEvent('append-ev');
await insertCat('First', { event_id: eventId, display_order: 1 });
await insertCat('Second', { event_id: eventId, display_order: 2 });
const res = await auth(request(app).post('/api/admin/categories'))
.send({ name: 'Third', is_global: false, event_id: eventId })
.expect(200);
expect(res.body.display_order).toBe(3);
});
});
});
@@ -0,0 +1,50 @@
/**
* Catalog-driven event-type defaults (#800 follow-up).
*
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
* the v1 API validated against a fixed whitelist. Both now follow the live
* event_types catalog; these tests pin the shared resolver.
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('resolveDefaultEventType follows the catalog', () => {
let db;
let cleanup;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so the service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it("prefers the 'other' catch-all while it is active", async () => {
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
});
it('falls over to the first active type when other is deactivated', async () => {
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
const resolved = await eventTypeService.resolveDefaultEventType();
expect(resolved).not.toBe('other');
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
});
it("returns the literal 'other' only for an empty catalog", async () => {
const rows = await db('event_types').select('*');
await db('event_types').del();
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
await db('event_types').insert(rows);
});
});
@@ -0,0 +1,133 @@
/**
* Setup-window event type deletion (#800).
*
* The first-run setup wizard may delete the seeded SYSTEM event types —
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
* seeds it false on a fresh install, true when an admin already exists).
* These tests pin the whole contract:
*
* - fresh install → flag false → system types deletable (in-use checks
* still apply), and the per-type reminder template goes with the type
* - reminder-template self-heal does NOT resurrect templates for slugs
* that no longer exist in the catalog
* - after markSetupWizardCompleted() → system deletion is refused again
*/
const { bootCrmDb } = require('./helpers/crmDb');
describe('event type deletion during the setup window (#800)', () => {
let db;
let cleanup;
let eventTypeService;
let setupService;
let ensureEventReminderTemplatesSeeded;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Require AFTER bootCrmDb so every service shares this db instance
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
eventTypeService = require('../../src/services/eventTypeService');
setupService = require('../../src/services/setupService');
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
expect(row).toBeTruthy();
expect(JSON.parse(row.setting_value)).toBe(false);
expect(await setupService.isSetupWizardCompleted()).toBe(false);
});
it('refuses to delete a system type that events already use, even in the window', async () => {
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
await db('events').insert({
slug: 'corporate-test-2026-01-01',
event_name: 'Test',
event_type: 'corporate',
event_date: '2026-01-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: 'share-corporate-test',
expires_at: new Date(Date.now() + 86400000),
});
await expect(eventTypeService.deleteEventType(corporate.id))
.rejects.toMatchObject({ code: 'IN_USE' });
});
it('deletes an unused system type in the window, taking its reminder template along', async () => {
// Seed the per-type reminder templates first so there is something to clean up.
await ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
expect(wedding.is_system).toBeTruthy();
const result = await eventTypeService.deleteEventType(wedding.id);
expect(result.success).toBe(true);
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// The deleted slug must NOT stay creatable through the legacy fallback —
// the live catalog is authoritative while it has rows.
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
});
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
// The seeder caches success per process — reset the module to force a
// genuine second pass, exactly what a backend restart would run.
jest.resetModules();
const fresh = require('../../src/services/eventReminderTemplates');
await fresh.ensureEventReminderTemplatesSeeded(db);
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
// Types still in the catalog keep their templates.
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
});
it('re-locks system types once the wizard is marked complete', async () => {
await setupService.markSetupWizardCompleted();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
await expect(eventTypeService.deleteEventType(birthday.id))
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
// Custom (non-system) types remain deletable as before.
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
const result = await eventTypeService.deleteEventType(custom.id);
expect(result.success).toBe(true);
});
it('fails closed when the completion marker row is missing', async () => {
// A portable-backup restore can replace app_settings with a set that
// predates migration 161 (which will not rerun) — absence must mean
// "configured instance", never an open deletion window.
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
expect(await setupService.isSetupWizardCompleted()).toBe(true);
await setupService.markSetupWizardCompleted();
});
it('refuses to delete the last remaining event type', async () => {
// Reduce the catalog to a single custom type via direct db writes (the
// service paths are already covered above), then hit the guard.
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
await db('events').del();
await db('event_types').whereNot('id', solo.id).del();
await expect(eventTypeService.deleteEventType(solo.id))
.rejects.toMatchObject({ code: 'LAST_TYPE' });
// Deactivating it would empty the ACTIVE catalog just the same.
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
});
});
@@ -234,3 +234,66 @@ describe('renderInvoiceToBuffer — Storno branch', () => {
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
});
});
// VAT free-text note (#794) + multi-page page-number placement. Same
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
// so we can't grep the note text — but the page-TREE objects are NOT
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
// pagination, and a byte-size delta proves the note actually rendered.
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
function baseCtx(overrides = {}) {
return {
locale: 'de', currency: 'CHF',
issuer: { companyName: 'AcmeCo' },
recipient: {
companyName: 'KundenCo', addressLine1: 'Strasse 1',
city: 'Bern', postalCode: '3000',
},
lineItems: [{
quantity: 1, description: 'Photo session',
unitPriceMinor: 30000, lineTotalMinor: 30000,
parentLineItemId: null, parentPosition: null,
}],
totals: {
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 30000,
},
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
qrFormat: 'none',
paymentTerm: { netDays: 30 },
...overrides,
};
}
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
expect(pageCount(withNote)).toBe(1);
expect(withNote.length).toBeGreaterThan(without.length);
});
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
const manyItems = Array.from({ length: 60 }, (_, i) => ({
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
unitPriceMinor: 3225, lineTotalMinor: 3225,
parentLineItemId: null, parentPosition: null,
}));
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
lineItems: manyItems,
totals: {
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
shippingAmountMinor: 0, totalAmountMinor: 193500,
},
vatNote: VAT_NOTE,
}));
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
const pages = pageCount(buf);
expect(pages).toBeGreaterThanOrEqual(2);
// 60 short rows fit in 23 pages; a stray blank page (the old margin
// bug) or a runaway loop would blow past this.
expect(pages).toBeLessThanOrEqual(3);
});
});
@@ -0,0 +1,37 @@
/**
* Migration 158: per-event slideshow ordering + category filter (#202).
*
* - `show_order` — 'chronological' (default, upload order) | 'random'
* (client-side shuffle). Lets the Live Slideshow play
* photos in a varied order during an event.
* - `show_category_id`— optional FK into `photo_categories`. When set, the
* slideshow only shows photos in that category (NULL =
* all visible photos, the existing behaviour).
*
* Both additive + guarded. Defaults preserve today's behaviour (chronological,
* all photos), so existing slideshows are unchanged.
*/
exports.up = async function up(knex) {
const hasOrder = await knex.schema.hasColumn('events', 'show_order');
if (!hasOrder) {
await knex.schema.alterTable('events', (t) => {
t.string('show_order', 20).defaultTo('chronological');
});
}
const hasCat = await knex.schema.hasColumn('events', 'show_category_id');
if (!hasCat) {
await knex.schema.alterTable('events', (t) => {
t.integer('show_category_id').nullable();
});
}
};
exports.down = async function down(knex) {
for (const col of ['show_order', 'show_category_id']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('events', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('events', (t) => t.dropColumn(col));
}
}
};
@@ -0,0 +1,57 @@
/**
* Migration 159: per-event category ordering (#782).
*
* Adds a `display_order` integer to `photo_categories` so photographers can
* arrange an event's categories in the flow of the day (Pre-Ceremony →
* Ceremony → Reception …) instead of the hard-coded AZ order. Mirrors the
* `display_order` column + reorder pattern already used by `event_types`.
*
* Preserve existing galleries: backfill `display_order` from the CURRENT
* (alphabetical) order, scoped — globals numbered together, event-specific
* numbered per event — so nothing reshuffles on upgrade. A custom order is
* opt-in via the admin reorder controls. See feedback: migrations should pin
* previously-implicit defaults onto existing rows.
*
* Backfill runs in JS (not a SQL window function) to stay portable across
* SQLite (dev) and Postgres (prod).
*
* Additive + hasColumn-guarded.
*/
async function addColumn(knex, table, column, builder) {
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
await addColumn(knex, 'photo_categories', 'display_order', (t) => {
t.integer('display_order').notNullable().defaultTo(0);
t.index('display_order');
});
// Backfill from the current alphabetical order, per scope, so existing
// galleries render exactly as before until an admin reorders.
const cats = await knex('photo_categories')
.select('id', 'name', 'is_global', 'event_id')
.orderBy('name', 'asc');
const counters = {};
for (const c of cats) {
const scope = c.is_global ? 'global' : `event:${c.event_id}`;
counters[scope] = (counters[scope] || 0) + 1;
await knex('photo_categories')
.where('id', c.id)
.update({ display_order: counters[scope] });
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasColumn('photo_categories', 'display_order')) {
await knex.schema.alterTable('photo_categories', (t) =>
t.dropColumn('display_order')
);
}
};
@@ -0,0 +1,46 @@
/**
* Migration 160: per-event category order override (#782).
*
* Builds on migration 159 (photo_categories.display_order = the GLOBAL default
* order) by adding a per-event OVERRIDE layer. Global categories are shared
* across every event, so a single display_order can only express one order for
* them. This table lets a single gallery arrange its categories — globals AND
* event-specific, interleaved into the flow of the day — independently of the
* global default.
*
* Resolution (see adminCategories / gallery):
* 1. if the event has override rows -> use override.position;
* 2. else fall back to photo_categories.display_order (the global default);
* 3. else name.
*
* An event is either "using the default" (no rows here) or "customised" (a row
* per category it shows). No backfill: every existing event starts on the
* default order, so nothing reshuffles — a custom order is opt-in per event.
*
* Additive + hasTable-guarded.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasTable('event_category_order')) return;
await knex.schema.createTable('event_category_order', (t) => {
t.increments('id').primary();
t.integer('event_id').notNullable()
.references('id').inTable('events').onDelete('CASCADE');
t.integer('category_id').notNullable()
.references('id').inTable('photo_categories').onDelete('CASCADE');
t.integer('position').notNullable().defaultTo(0);
t.timestamp('created_at').defaultTo(knex.fn.now());
// At most one position per (event, category).
t.unique(['event_id', 'category_id']);
// Ordered reads are always scoped to one event.
t.index(['event_id', 'position']);
});
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('event_category_order')) {
await knex.schema.dropTable('event_category_order');
}
};
@@ -0,0 +1,43 @@
/**
* Migration 161: `setup_wizard_completed` app setting (#800).
*
* The setup wizard gains an event-types step that may rename or DELETE the
* seeded system event types. That is only safe on a pristine install, so the
* backend gates system-type deletion on this flag being unset (plus zero
* usage — see eventTypeService.deleteEventType).
*
* Backfill rule: any install that already has an admin account predates the
* wizard step (or already finished the wizard), so it is marked completed
* here — the deletion window never opens on existing setups. A genuinely
* fresh install runs this migration BEFORE its first admin is created, so
* the flag starts false and the wizard's finish call flips it to true.
*
* Idempotent: skips when the key already exists. Values are JSON-stringified
* to match getAppSetting's JSON.parse on read.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where({ setting_key: 'setup_wizard_completed' })
.first();
if (existing) return;
let hasAdmin = false;
if (await knex.schema.hasTable('admin_users')) {
const row = await knex('admin_users').count({ c: '*' }).first();
hasAdmin = Number(row?.c || 0) > 0;
}
await knex('app_settings').insert({
setting_key: 'setup_wizard_completed',
setting_value: JSON.stringify(hasAdmin),
setting_type: 'boolean',
updated_at: new Date(),
});
};
exports.down = async function down(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.83.0-beta.0",
"version": "3.88.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+154 -14
View File
@@ -4,6 +4,8 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const logger = require('../utils/logger');
const router = express.Router();
@@ -12,8 +14,9 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
try {
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error fetching categories:', error);
@@ -21,19 +24,12 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
}
});
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
// Get categories for a specific event (global + event-specific), resolved to
// the event's effective order: per-event override, else global default, else
// name (#782). Each row carries `override_position` (null when not customised).
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
const categories = await getEventCategoriesOrdered(req.params.eventId);
res.json(categories);
} catch (error) {
logger.error('Error fetching event categories:', error);
@@ -81,12 +77,27 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
return res.status(400).json({ error: 'Category with this slug already exists' });
}
// Append to the end of its scope so a new category doesn't jump to the
// top of an admin-defined order (#782).
const maxRow = await db('photo_categories')
.where(function() {
if (is_global) {
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
})
.max('display_order as maxOrder')
.first();
const nextOrder = (maxRow?.maxOrder || 0) + 1;
// Create category
const insertResult = await db('photo_categories').insert({
name,
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id
event_id: is_global ? null : event_id,
display_order: nextOrder
}).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0];
@@ -254,4 +265,133 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
}
});
// Set a per-event category order override (#782). The client sends the full
// ordered id list for THIS event — globals + event-specific, interleaved — and
// we replace the event's override rows in one transaction. This overrides the
// global default order for this gallery only.
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
body('event_id').isInt().withMessage('event_id must be an integer'),
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const eventId = parseInt(req.body.event_id, 10);
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
// Event ownership (event_id comes from the body, so requireEventOwnership —
// which reads req.params — can't be used here). Mirror it: super_admins
// bypass; other admins may only reorder events they own (ownerless
// legacy/system events allowed).
if (req.admin.roleName !== 'super_admin') {
const event = await db('events').where('id', eventId).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
}
// Every id must be a category available to this event: a shared global OR
// one of the event's own categories. Anything else is out of scope.
const available = await db('photo_categories')
.where(function() {
this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId);
})
.pluck('id');
const availableSet = new Set(available);
const invalid = orderedIds.filter((id) => !availableSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not available for this event' });
}
await db.transaction(async (trx) => {
await trx('event_category_order').where('event_id', eventId).del();
await trx('event_category_order').insert(
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
);
});
// Log activity after commit (avoids a SQLite in-transaction global write).
await logActivity('event_category_order_set',
{ eventId, count: orderedIds.length },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error reordering categories:', error);
res.status(500).json({ error: 'Failed to reorder categories' });
}
});
// Clear an event's override — revert this gallery to the global default order.
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId, 10);
await db('event_category_order').where('event_id', eventId).del();
await logActivity('event_category_order_reset',
{ eventId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(await getEventCategoriesOrdered(eventId));
} catch (error) {
logger.error('Error resetting category order:', error);
res.status(500).json({ error: 'Failed to reset category order' });
}
});
// Set the GLOBAL default order for shared (global) categories (#782). Applies
// to every gallery that hasn't set its own override. Rewrites display_order.
router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
const globalsSet = new Set(globals);
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
if (invalid.length > 0) {
return res.status(400).json({ error: 'One or more categories are not global' });
}
await db.transaction(async (trx) => {
for (let i = 0; i < orderedIds.length; i += 1) {
await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 });
}
});
await logActivity('global_category_order_set',
{ count: orderedIds.length },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
logger.error('Error reordering global categories:', error);
res.status(500).json({ error: 'Failed to reorder global categories' });
}
});
module.exports = router;
+2 -2
View File
@@ -178,7 +178,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
if (error.code === 'DUPLICATE_SLUG_PREFIX' || error.code === 'LAST_ACTIVE') {
return res.status(400).json({ error: error.message });
}
@@ -216,7 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE' || error.code === 'LAST_TYPE') {
return res.status(400).json({ error: error.message });
}
@@ -307,6 +307,9 @@ async function deleteEventCascade(eventId, adminContext) {
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
// Allowed per-slide color filters.
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
// Allowed slideshow play orders (#202). 'chronological' = upload order,
// 'random' = client-side shuffle.
const SLIDESHOW_ORDERS = ['chronological', 'random'];
module.exports = {
validateHeroImageAnchor,
getStoragePath,
@@ -321,6 +324,7 @@ module.exports = {
mapEventForApi,
hasCustomerContactColumns,
deleteEventCascade,
SLIDESHOW_ORDERS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
+24 -3
View File
@@ -13,7 +13,7 @@ const { parseBooleanInput } = require('../../utils/parsers');
const { requireEventOwnership } = require('../../middleware/ownership');
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, SLIDESHOW_ORDERS } = require('./helpers');
// The watermark LOOK (source/position/opacity/style/size) is global-only
// (app_settings, Settings → Slideshow); events only carry the show_watermark
@@ -105,7 +105,9 @@ module.exports = (router) => {
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
body('show_watermark').optional({ nullable: true }),
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS),
body('show_order').optional().isIn(SLIDESHOW_ORDERS),
body('show_category_id').optional({ nullable: true }).isInt({ min: 1 })
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -130,6 +132,23 @@ module.exports = (router) => {
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
}
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
if (req.body.show_order !== undefined) updates.show_order = req.body.show_order;
// Category filter (#202). null clears it (all photos). A non-null id must
// belong to this event or be a global category — otherwise ignore it so a
// stale/foreign id can't leak another event's category selection.
if (req.body.show_category_id !== undefined) {
if (req.body.show_category_id === null) {
updates.show_category_id = null;
} else {
const catId = parseInt(req.body.show_category_id, 10);
const cat = await db('photo_categories')
.where({ id: catId })
.where(function () { this.where('event_id', event.id).orWhere('is_global', formatBoolean(true)); })
.first();
if (!cat) return res.status(400).json({ error: 'Category does not belong to this event' });
updates.show_category_id = catId;
}
}
// Knex throws on an empty update; only write if something changed.
if (Object.keys(updates).length > 0) {
@@ -141,7 +160,9 @@ module.exports = (router) => {
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none',
show_order: updates.show_order ?? event.show_order ?? 'chronological',
show_category_id: 'show_category_id' in updates ? updates.show_category_id : (event.show_category_id ?? null)
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to update slideshow settings');
+17 -4
View File
@@ -31,6 +31,19 @@ const watermarkGeneratorService = require('../services/watermarkGeneratorService
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Reserved first-run bootstrap keys — never writable through the generic
// settings upserts in this file: setup_wizard_completed is a one-way marker
// (#800; writing false would reopen system-event-type deletion) and
// setup_token is the first-run bootstrap secret. Every handler that loops
// arbitrary request keys into app_settings must strip these first.
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
const stripReservedSettingKeys = (settings) => {
for (const key of RESERVED_SETTING_KEYS) {
delete settings[key];
}
return settings;
};
// Configure multer for logo uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
@@ -906,7 +919,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
// Update general settings
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = { ...req.body };
const settings = stripReservedSettingKeys({ ...req.body });
let uploadLimitTouched = false;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
@@ -1017,7 +1030,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
// Update security settings
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
const settings = stripReservedSettingKeys({ ...req.body });
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
@@ -1055,7 +1068,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
// Update analytics settings
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
const settings = stripReservedSettingKeys({ ...req.body });
// Validate the provider switch (#663 Phase 1). Reject unknown values
// so the dashboard route's factory doesn't have to defensively guard.
@@ -1110,7 +1123,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
// Update SEO settings
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
const settings = stripReservedSettingKeys({ ...req.body });
// Validate seo_blocked_ai_agents is an array of strings
if (settings.seo_blocked_ai_agents !== undefined) {
+11
View File
@@ -45,6 +45,17 @@ const router = express.Router();
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
// A normal login means the first-run wizard is over — the wizard never hits
// this route (setup sets its cookie directly). Close the system-event-type
// deletion window durably even when the wizard was abandoned mid-way (#800).
// Best-effort: a failure here must never block a login.
try {
const setupService = require('../services/setupService');
if (!(await setupService.isSetupWizardCompleted())) {
await setupService.markSetupWizardCompleted();
}
} catch (_) { /* best-effort */ }
await db('admin_users').where('id', admin.id).update({
last_login: new Date(),
last_login_ip: ipAddress
+25 -8
View File
@@ -25,6 +25,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
@@ -243,8 +244,8 @@ router.get('/:slug/info', async (req, res) => {
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
// guest filter in GET /:slug/photos so the live count matches the rendered set.
function slideshowPhotosQuery(eventId) {
return db('photos')
function slideshowPhotosQuery(eventId, categoryId = null) {
const q = db('photos')
.where('photos.event_id', eventId)
.where(function() {
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
@@ -252,6 +253,10 @@ function slideshowPhotosQuery(eventId) {
.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
// Category filter (#202) — keep the /session + /state count in sync with the
// photos the kiosk actually renders.
if (categoryId) q.where('photos.category_id', categoryId);
return q;
}
// Resolve an active slideshow by slug + token. Returns the event row, or null
@@ -324,6 +329,9 @@ async function slideshowSettings(event) {
transition: event.show_transition || 'crossfade',
transition_ms: event.show_transition_ms || 800,
colorfilter: event.show_colorfilter || 'none',
// Play order (#202): 'chronological' | 'random'. The client shuffles when
// 'random' so live-appended uploads keep working.
order: event.show_order || 'chronological',
fit: g.fit,
watermark,
};
@@ -356,7 +364,7 @@ router.get('/:slug/show/:token/session', handleAsync(async (req, res) => {
// here so the kiosk's image requests are authorized with zero extra wiring.
setGalleryAuthCookies(res, sessionToken, event.slug);
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
token: sessionToken,
@@ -382,7 +390,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
throw new NotFoundError('Slideshow');
}
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
res.json({
...(await slideshowSettings(event)),
@@ -426,6 +434,13 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
});
}
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
// viewer can't widen the set: when the event pins show_category_id, the
// slideshow only sees that category. NULL = all photos (unchanged).
if (req.accessLevel === 'slideshow' && req.event.show_category_id) {
photosQuery = photosQuery.where('photos.category_id', req.event.show_category_id);
}
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
@@ -573,10 +588,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
.orderBy('name', 'asc');
// Resolved category order (#782): per-event override, else global
// default, else name — restricted to categories that have photos.
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
onlyIds: usedCategoryIds,
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
});
categories = categoryDetails.map(cat => ({
id: cat.id,
+16
View File
@@ -10,6 +10,7 @@ const { body, validationResult } = require('express-validator');
const setupService = require('../services/setupService');
const { getClientIp } = require('../utils/requestIp');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { adminAuth } = require('../middleware/auth');
const logger = require('../utils/logger');
const router = express.Router();
@@ -79,4 +80,19 @@ router.post('/admin', [
}
});
// Wizard finish marker — unlike the endpoints above this one runs AFTER the
// admin exists (the wizard is authenticated from the account step onward), so
// it takes the normal admin auth. One-way: while the flag is unset the seeded
// SYSTEM event types may be deleted from the wizard's event-types step; once
// set they are permanently protected (#800).
router.post('/complete', adminAuth, async (req, res) => {
try {
await setupService.markSetupWizardCompleted();
res.json({ completed: true });
} catch (err) {
logger.error('[setup] markSetupWizardCompleted failed', { error: err.message });
res.status(500).json({ error: 'Failed to mark setup complete' });
}
});
module.exports = router;
@@ -80,7 +80,16 @@ jest.mock('../../../services/webhookService', () => ({
buildEventSubject: jest.fn().mockReturnValue({}),
}));
// event_type is validated against the live event_types catalog (#800) —
// that lookup would consume the first queued db() chain and shift the
// call sequence these tests pin. Stub it valid; the invalid path has its
// own test below.
jest.mock('../../../services/eventTypeService', () => ({
isValidEventType: jest.fn().mockResolvedValue(true),
}));
const { db } = require('../../../database/db');
const { isValidEventType } = require('../../../services/eventTypeService');
const eventsRouter = require('../events');
const buildApp = () => {
@@ -231,4 +240,16 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
isValidEventType.mockResolvedValueOnce(false);
const res = await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, event_type: 'nope' })
.expect(400);
expect(isValidEventType).toHaveBeenCalledWith('nope');
expect(JSON.stringify(res.body.errors)).toContain('event_type');
expect(db).not.toHaveBeenCalled();
});
});
+52 -2
View File
@@ -26,6 +26,7 @@ const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const { isValidEventType } = require('../../services/eventTypeService');
const router = express.Router();
@@ -80,7 +81,7 @@ const photoUpload = multer({
* event_name: { type: string }
* event_type:
* type: string
* enum: [wedding, birthday, corporate, other, family]
* description: "Slug of an active event type from the catalog (Settings → Event Types). Defaults on a fresh install: wedding, birthday, corporate, other. GET /api/v1/event-types lists the live values."
* event_date: { type: string, format: date, nullable: true }
* customer_name: { type: string, nullable: true }
* customer_email: { type: string, format: email, nullable: true }
@@ -117,7 +118,14 @@ router.post(
requireApiScope('admin'),
[
body('event_name').isString().trim().notEmpty(),
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']),
// Validate against the live event_types catalog (admins can rename/delete
// the defaults and add custom types), not a hardcoded whitelist (#800).
body('event_type').isString().trim().notEmpty().bail().custom(async (value) => {
if (!(await isValidEventType(value))) {
throw new Error('Unknown event type — must match an active event type slug');
}
return true;
}),
body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('customer_name').optional({ nullable: true }).isString(),
body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
@@ -447,6 +455,48 @@ router.get(
}
);
// ──────────────────────────────────────────────────────────────────────────
// GET /event-types — read (catalog discovery for event creation, #800)
// ──────────────────────────────────────────────────────────────────────────
/**
* @openapi
* /event-types:
* get:
* tags: [Events]
* summary: List active event types
* description: The slugs accepted as `event_type` when creating events. The catalog is admin-customizable (Settings → Event Types), so integrations should discover values here instead of hardcoding them.
* security: [{ bearerAuth: [] }]
* responses:
* 200:
* description: Active event types
* content:
* application/json:
* schema:
* type: object
* properties:
* eventTypes:
* type: array
* items:
* type: object
* properties:
* slug_prefix: { type: string }
* name: { type: string }
* emoji: { type: string }
*/
router.get('/event-types', apiTokenAuth, requireApiScope('read'), async (req, res) => {
try {
const types = await db('event_types')
.where('is_active', formatBoolean(true))
.orderBy('display_order', 'asc')
.select('slug_prefix', 'name', 'emoji');
res.json({ eventTypes: types });
} catch (error) {
logger.error('v1 GET /event-types failed', { error: error.message });
res.status(500).json({ error: 'Failed to list event types' });
}
});
// ──────────────────────────────────────────────────────────────────────────
// GET /events/:id — read
// ──────────────────────────────────────────────────────────────────────────
+8 -1
View File
@@ -11,6 +11,7 @@ const businessProfileService = require('../businessProfileService');
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
const { ensureInt } = require('../../utils/numericHelpers');
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
const { resolveDefaultEventType } = require('../eventTypeService');
/**
@@ -221,6 +222,12 @@ async function convertToEvent(contractId, adminId) {
const placeholderHash = crypto.randomBytes(32).toString('hex');
const shareToken = crypto.randomBytes(32).toString('hex');
// Event type: the configurable org default, else the resolved catch-all —
// same chain as quoteService.convertToEvent. Never a hardcoded slug: the
// admin may have renamed or deleted 'wedding' (#800).
const eventType = (await getAppSetting('crm_default_event_type'))
|| (await resolveDefaultEventType());
const eventCols = await db('events').columnInfo();
const candidate = {
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
@@ -236,7 +243,7 @@ async function convertToEvent(contractId, adminId) {
customer_email: customerEmail,
customer_phone: customer.phone,
admin_email: adminEmail,
event_type: 'wedding',
event_type: eventType,
password_hash: placeholderHash,
share_link: shareToken,
share_token: shareToken,
@@ -266,7 +266,22 @@ async function ensureEventReminderTemplatesSeeded(db, logger) {
}
};
// Per-type templates are only seeded for slugs that still exist in the
// event_types catalog — the setup wizard (and admins) can delete the
// seeded defaults, and re-inserting event_reminder_<slug> for a removed
// type would resurrect an orphan on every boot (#800). The catch-all
// event_reminder_default is always seeded.
let existingSlugs = null;
if (await db.schema.hasTable('event_types')) {
const rows = await db('event_types').select('slug_prefix');
existingSlugs = new Set(rows.map((r) => r.slug_prefix));
}
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
const typeSlug = templateKey.replace(/^event_reminder_/, '');
if (typeSlug !== 'default' && existingSlugs && !existingSlugs.has(typeSlug)) {
continue;
}
try {
let existing = await db('email_templates').where({ template_key: templateKey }).first();
+120 -10
View File
@@ -67,13 +67,20 @@ const getEventTypeBySlugPrefix = async (slugPrefix) => {
const isValidEventType = async (slugPrefix) => {
const normalized = slugPrefix.toLowerCase();
// Check in database
// The live catalog is authoritative: a row decides by its active flag, and
// a slug the admin deleted (setup wizard, #800) or deactivated must NOT
// sneak back in through the legacy list below.
const eventType = await getEventTypeBySlugPrefix(normalized);
if (eventType && eventType.is_active) {
return true;
if (eventType) {
return Boolean(eventType.is_active);
}
const anyType = await db('event_types').first('id');
if (anyType) {
return false;
}
// Legacy fallback: Accept old hardcoded values for backward compatibility
// Legacy fallback: only for a degenerate install with an EMPTY catalog
// (pre-catalog schema drift) — accept the old hardcoded values.
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
return legacyTypes.includes(normalized);
};
@@ -198,6 +205,19 @@ const updateEventType = async (id, updates) => {
}
if (updates.is_active !== undefined) {
// Deactivating the last active type would empty the ACTIVE catalog and
// brick event creation (unknown slugs are rejected since #800).
if (updates.is_active === false && eventType.is_active) {
const otherActive = await db('event_types')
.whereNot('id', id)
.where('is_active', formatBoolean(true))
.first('id');
if (!otherActive) {
const error = new Error('Cannot deactivate the last active event type — activate another one first.');
error.code = 'LAST_ACTIVE';
throw error;
}
}
updateData.is_active = formatBoolean(updates.is_active);
}
@@ -250,6 +270,12 @@ const updateEventType = async (id, updates) => {
/**
* Delete an event type
*
* System types are protected — EXCEPT during the first-run setup wizard
* (setup_wizard_completed flag unset, see setupService), where the admin may
* replace the seeded defaults before anything references them (#800). The
* in-use checks below still apply in that window as defense in depth.
*
* @param {number} id - Event type ID
* @returns {Promise<Object>}
*/
@@ -261,11 +287,17 @@ const deleteEventType = async (id) => {
throw error;
}
// Prevent deletion of system types
// Prevent deletion of system types once the setup wizard has completed.
if (eventType.is_system) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
// Lazy require: keeps the module graph flat (setupService has no
// dependency back on this service, but the require is only needed on
// this rare path).
const { isSetupWizardCompleted } = require('./setupService');
if (await isSetupWizardCompleted()) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
}
}
// Check if any events use this type
@@ -280,7 +312,62 @@ const deleteEventType = async (id) => {
throw error;
}
await db('event_types').where('id', id).del();
// Never delete the last remaining type — and never delete the last ACTIVE
// one either: event creation and the quote/contract default-type resolution
// both need at least one active catalog entry.
const remaining = await db('event_types').whereNot('id', id).count('id as count').first();
if (!remaining || parseInt(remaining.count) === 0) {
const error = new Error('Cannot delete the last event type — at least one must remain.');
error.code = 'LAST_TYPE';
throw error;
}
if (eventType.is_active) {
const remainingActive = await db('event_types')
.whereNot('id', id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (!remainingActive || parseInt(remainingActive.count) === 0) {
const error = new Error('Cannot delete the last active event type — activate another one first.');
error.code = 'LAST_TYPE';
throw error;
}
}
// Quotes carry event_type too (migration 146) — a dangling slug there would
// corrupt the quote→event conversion default chain.
if (await hasColumnCached('quotes', 'event_type')) {
const quotesUsingType = await db('quotes')
.where('event_type', eventType.slug_prefix)
.count('id as count')
.first();
if (quotesUsingType && parseInt(quotesUsingType.count) > 0) {
const error = new Error(`Cannot delete: ${quotesUsingType.count} quotes are using this type. Deactivate it instead.`);
error.code = 'IN_USE';
throw error;
}
}
// Resolve schema lookups BEFORE opening the transaction — a global-db read
// inside a SQLite transaction (single connection) deadlocks. Same pattern
// as the rename cascade in updateEventType above.
const hasTranslations = await db.schema.hasTable('email_template_translations');
await db.transaction(async (trx) => {
await trx('event_types').where('id', id).del();
// Drop the per-type reminder template with the type, or it lingers as an
// orphan (invisible in the Reminder Emails tab, which derives its rows
// from the live catalog).
const tpl = await trx('email_templates')
.where({ template_key: `event_reminder_${eventType.slug_prefix}` })
.first('id');
if (tpl) {
if (hasTranslations) {
await trx('email_template_translations').where({ template_id: tpl.id }).del();
}
await trx('email_templates').where({ id: tpl.id }).del();
}
});
return { success: true, deleted: eventType };
};
@@ -341,6 +428,28 @@ const getEventTypeForSlug = async (eventTypeIdentifier) => {
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
};
/**
* Resolve the fallback event type for document→event conversions (quotes,
* contracts) when the source carries none. Never hardcodes a specific slug
* (any of them, incl. 'other', can be disabled by the admin): prefer the
* generic 'other' catch-all when it's active, else the first active type by
* display order, and only fall back to the literal 'other' if the catalog is
* somehow empty/unreadable.
* @param {Object} [conn] - Optional knex connection/transaction
* @returns {Promise<string>} - slug_prefix to use
*/
const resolveDefaultEventType = async (conn) => {
const q = conn || db;
try {
const other = await q('event_types').where({ slug_prefix: 'other', is_active: formatBoolean(true) }).first('slug_prefix');
if (other) return 'other';
const firstActive = await q('event_types').where({ is_active: formatBoolean(true) }).orderBy('display_order', 'asc').first('slug_prefix');
return firstActive?.slug_prefix || 'other';
} catch (_) {
return 'other';
}
};
module.exports = {
getAllEventTypes,
getActiveEventTypes,
@@ -352,5 +461,6 @@ module.exports = {
updateEventType,
deleteEventType,
reorderEventTypes,
getEventTypeForSlug
getEventTypeForSlug,
resolveDefaultEventType
};
+10
View File
@@ -174,6 +174,14 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
? 0
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
// Optional free-text VAT / legal note printed directly under the MwSt. line
// on the invoice PDF (#794). Configured globally in Settings → CRM → Invoices.
// Data-driven: the admin types the exact wording (e.g. the Austrian
// Kleinunternehmer statement, § 6 Abs. 1 Z 27 UStG 1994), so no jurisdiction
// is hardcoded. Empty/whitespace → null (row omitted).
const vatNoteRaw = await getAppSetting('crm_invoices_vat_note_text');
const vatNote = typeof vatNoteRaw === 'string' && vatNoteRaw.trim() ? vatNoteRaw.trim() : null;
return {
locale: invoice.language || profile?.default_locale || 'de',
currency: invoice.currency,
@@ -189,6 +197,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
iban: bank.iban, bic: bank.bic, currency: bank.currency,
} : null,
paymentTerm,
// Free-text VAT/legal note (#794) — rendered under the MwSt. line by drawTotals.
vatNote,
lineItems: lineItems.map((li) => ({
quantity: li.quantity,
description: li.description,
+36 -4
View File
@@ -851,6 +851,18 @@ function drawTotals(doc, ctx, x, y, width) {
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
// Free-text VAT / legal note (#794) — printed directly under the MwSt. line
// (Benedikt's requested spot). The admin sets the exact wording in
// Settings → CRM → Invoices (e.g. the Austrian Kleinunternehmer statement).
// Optional; wraps across the totals column. Font size is restored to the row
// scale so the Mahngebühr / Rundung / grand-total rows below are unaffected.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#555');
doc.text(ctx.vatNote, labelX, y, { width: right - labelX });
doc.fillColor('#000').fontSize(10);
y = doc.y + 4;
}
// Mahngebühr row — only rendered when a late fee has been added
// (second reminder onwards). Sits between VAT and the grand-total
// divider so the customer sees a clear "VAT + late fee → Total"
@@ -1670,7 +1682,16 @@ function renderDocument(type, context) {
// VAT + middle divider + Total)
const FOOTER_RESERVE = 30;
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
const TOTALS_BLOCK_HEIGHT = 90;
let TOTALS_BLOCK_HEIGHT = 90;
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
// grow the reserved totals height by its measured height so a long note
// can't push the grand total / payment block into the footer.
if (ctx.vatNote) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
doc.fontSize(10);
}
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
@@ -1746,14 +1767,23 @@ function renderDocument(type, context) {
// grey line in the bottom corner) is negligible.
for (let i = 0; i < total; i++) {
doc.switchToPage(range.start + i);
// Drop this page's bottom margin to 0 so writing the label INTO the
// margin band (below the content area the line-item table fills) can't
// trigger PDFKit's auto-page-break. Previously the label sat at
// marginBottom-12 — INSIDE the content area — so on a full multi-page
// invoice the table's last row overlapped the "Seite X von Y" stamp
// (#794). The page is already fully laid out (buffered), so zeroing the
// margin here is safe.
doc.page.margins.bottom = 0;
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
const label = t(ctx.locale, 'page_of', {
current: i + 1,
total,
});
// Bottom-right corner, just above the bottom margin so
// it doesn't trigger PDFKit's auto-paging.
const labelY = doc.page.height - PAGE.marginBottom - 12;
// Bottom-right corner, INSIDE the bottom margin (below the content
// edge the table fills), so a full continuation page's last row can't
// overlap it.
const labelY = doc.page.height - PAGE.marginBottom + 8;
const labelW = 120;
const labelX = doc.page.width - PAGE.marginRight - labelW;
doc.text(label, labelX, labelY, {
@@ -1793,6 +1823,8 @@ function normaliseContext(type, ctx) {
totals: ctx.totals || {},
doc: ctx.doc || {},
qrFormat: ctx.qrFormat || 'none',
// Free-text VAT/legal note printed under the MwSt. line on invoices (#794).
vatNote: (typeof ctx.vatNote === 'string' && ctx.vatNote.trim()) ? ctx.vatNote.trim() : null,
// Date-format config from the `general_date_format` app setting.
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
+1 -19
View File
@@ -34,6 +34,7 @@ const { cleanNetMinor } = require('../utils/invoiceRounding');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { nextDocumentNumber } = require('../utils/documentSequences');
const { resolveDefaultEventType } = require('./eventTypeService');
const { formatShortDate } = require('../utils/dateFormatter');
const businessProfileService = require('./businessProfileService');
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
@@ -308,25 +309,6 @@ async function nextQuoteNumber(trx) {
return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx);
}
/**
* Resolve the fallback event type for a quote→event conversion when the quote
* itself carries none. Never hardcodes a specific slug (any of them, incl.
* 'other', can be disabled by the admin): prefer the generic 'other' catch-all
* when it's active, else the first active type by display order, and only fall
* back to the literal 'other' if the catalog is somehow empty/unreadable.
*/
async function resolveDefaultEventType(conn) {
const q = conn || db;
try {
const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix');
if (other) return 'other';
const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix');
return firstActive?.slug_prefix || 'other';
} catch (_) {
return 'other';
}
}
function ensureCustomerFeatureEnabled(customer, feature) {
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
// is checked at the route layer (feature flag); here we only enforce
+28 -1
View File
@@ -20,6 +20,26 @@ const { formatBoolean } = require('../utils/dbCompat');
// is permanently closed once setup is done — safe even on a public IP.
const SETUP_TOKEN_KEY = 'setup_token';
// One-way flag flipped when the setup wizard finishes (migration 161 marks it
// completed on installs that predate the wizard's event-types step). While it
// is unset — i.e. only during the first-run wizard — the seeded SYSTEM event
// types may be deleted (eventTypeService.deleteEventType), because nothing
// can reference them yet. Once true, system types are permanently protected.
const SETUP_WIZARD_COMPLETED_KEY = 'setup_wizard_completed';
async function isSetupWizardCompleted() {
// Fail closed: only an explicit stored `false` (seeded by migration 161 on
// a fresh, admin-less install) opens the deletion window. A missing row —
// e.g. app_settings replaced by a portable-backup restore that predates the
// migration, which will not rerun — means a configured instance, not a
// first run.
return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) !== false;
}
async function markSetupWizardCompleted() {
await upsertAppSetting(SETUP_WIZARD_COMPLETED_KEY, JSON.stringify(true), 'boolean');
}
async function noAdminExists() {
const row = await db('admin_users').count({ c: '*' }).first();
return Number(row?.c || 0) === 0;
@@ -173,4 +193,11 @@ async function createInitialAdmin({ token, email, password, ip }) {
};
}
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
module.exports = {
getSetupStatus,
ensureSetupToken,
verifySetupToken,
createInitialAdmin,
isSetupWizardCompleted,
markSetupWizardCompleted,
};
+63
View File
@@ -0,0 +1,63 @@
/**
* Category order resolution (#782).
*
* Resolves an event's categories into their effective display order, layering:
* 1. per-event override — event_category_order.position, when the event has
* been customised;
* 2. the global default — photo_categories.display_order (migration 159);
* 3. name.
*
* Globals and event-specific categories are ordered together so a custom order
* can interleave them into the flow of the day. Shared by the admin event view
* and the public gallery so the two never diverge.
*/
const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const { hasColumnCached } = require('./schemaCache');
/**
* @param {number|string} eventId
* @param {object} [opts]
* @param {number[]|null} [opts.onlyIds] restrict to these category ids (the
* public gallery only shows categories that actually have photos).
* @param {string[]|null} [opts.select] qualified columns to select (default
* `c.*`). Always aliased to the `photo_categories as c` table.
* @returns rows with an added `override_position` (null when not customised).
*/
async function getEventCategoriesOrdered(eventId, { onlyIds = null, select = null } = {}) {
const eid = parseInt(eventId, 10);
const base = db('photo_categories as c').where(function () {
this.where('c.is_global', formatBoolean(true)).orWhere('c.event_id', eid);
});
if (onlyIds) base.whereIn('c.id', onlyIds);
// Fail safe: if the override table isn't present yet (half-applied migration),
// fall back to the global-default order so the public gallery never 500s.
const overrideReady = await hasColumnCached('event_category_order', 'position');
if (!overrideReady) {
return base
.select(select || 'c.*')
.orderBy('c.is_global', 'desc')
.orderBy('c.display_order', 'asc')
.orderBy('c.name', 'asc');
}
const cols = select ? [...select] : ['c.*'];
cols.push('o.position as override_position');
return base
.leftJoin('event_category_order as o', function () {
this.on('o.category_id', 'c.id').andOnVal('o.event_id', '=', eid);
})
.select(cols)
// Overridden categories first (in their pinned order), then the rest by the
// global default. CASE keeps NULL-ordering portable across SQLite + Postgres.
.orderByRaw('CASE WHEN o.position IS NULL THEN 1 ELSE 0 END ASC')
.orderBy('o.position', 'asc')
.orderBy('c.is_global', 'desc')
.orderBy('c.display_order', 'asc')
.orderBy('c.name', 'asc');
}
module.exports = { getEventCategoriesOrdered };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.83.0-beta.0",
"version": "3.88.0-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -15,11 +15,13 @@ import {
Workflow,
PanelLeftClose,
PanelLeftOpen,
Github,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { repoUrl } from '../../utils/githubReleaseUrl';
import { usePermissions } from '../../contexts/PermissionsContext';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -324,6 +326,20 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
{/* Storage Info */}
<StorageInfo />
{/* Link to the project on GitHub (#778). Subtle footer row so
admins can reach the repo — star, source, report an issue —
from anywhere in the dashboard, not just the setup screen. */}
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="mx-4 mb-3 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
title={t('admin.viewOnGithub', 'View PicPeak on GitHub')}
>
<Github className="w-3.5 h-3.5" />
<span>{t('admin.viewOnGithub', 'View PicPeak on GitHub')}</span>
</a>
</div>
)}
</div>
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
@@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => {
const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState('');
// Fetch global categories
// Fetch global categories (ordered by the global default display_order)
const { data: categories = [], isLoading } = useQuery({
queryKey: ['global-categories'],
queryFn: categoriesService.getGlobalCategories,
});
// Local copy so the up/down reorder buttons feel instant; resynced when the
// query data changes.
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
useEffect(() => {
setOrdered(categories);
}, [categories]);
// Set the GLOBAL default order (#782). Applies to every gallery that hasn't
// set its own per-event override.
const reorderMutation = useMutationWithToast({
mutationFn: (orderedIds: number[]) => categoriesService.reorderGlobalCategories(orderedIds),
invalidateKeys: [['global-categories']],
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
});
const handleMove = (index: number, dir: -1 | 1) => {
const target = index + dir;
if (target < 0 || target >= ordered.length) return;
const next = [...ordered];
[next[index], next[target]] = [next[target], next[index]];
setOrdered(next); // optimistic
reorderMutation.mutate(next.map((c) => c.id));
};
// Create category mutation
const createMutation = useMutationWithToast({
mutationFn: (name: string) =>
@@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => {
{/* Categories list */}
<div className="space-y-2">
{categories.length === 0 ? (
{ordered.length === 0 ? (
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('categories.noCategoriesYet')}
</p>
) : (
categories.map((category) => (
ordered.map((category, index) => (
<div
key={category.id}
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
@@ -189,9 +213,33 @@ export const CategoryManager: React.FC = () => {
</div>
) : (
<>
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
<div className="flex items-center gap-2 min-w-0">
{/* Global default order (#782). The gallery uses this order
unless a specific event overrides it. */}
<div className="flex flex-col -space-y-1">
<button
onClick={() => handleMove(index, -1)}
disabled={index === 0 || reorderMutation.isPending}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveUp', 'Move up')}
aria-label={t('categories.moveUp', 'Move up')}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
onClick={() => handleMove(index, 1)}
disabled={index === ordered.length - 1 || reorderMutation.isPending}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveDown', 'Move down')}
aria-label={t('categories.moveDown', 'Move down')}
>
<ArrowDown className="w-4 h-4" />
</button>
</div>
<div className="min-w-0">
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{category.name}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">/{category.slug}</p>
</div>
</div>
<div className="flex gap-1">
<button
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service';
import { Button, Card, AuthenticatedImage } from '../common';
@@ -17,7 +17,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
// Fetch categories for this event
// Fetch this event's categories (globals + event-specific), already resolved
// to the event's effective order by the backend (#782).
const { data: categories = [], isLoading } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
@@ -30,17 +31,21 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
enabled: heroPickerCategoryId !== null,
});
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Combined list (globals + event-specific) in the resolved order, kept in
// local state so the up/down reorder buttons feel instant; resynced whenever
// the query data changes (e.g. after a reorder or reset persists).
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
useEffect(() => {
setOrdered(categories);
}, [categories]);
// Create category mutation
// The event is "customised" when it has its own per-event override.
const isCustomised = ordered.some((c) => c.override_position != null);
// Create category mutation (always event-specific)
const createMutation = useMutationWithToast({
mutationFn: (name: string) =>
categoriesService.createCategory({
name,
is_global: false,
event_id: eventId
}),
categoriesService.createCategory({ name, is_global: false, event_id: eventId }),
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.categoryCreatedSuccess'),
onSuccess: () => {
@@ -71,9 +76,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
errorMessage: t('categories.failedToSetCoverPhoto'),
});
// Toggle per-category download permission (#640). The backend AND's this
// with the event-level `allow_downloads`, so disabling at either level
// blocks downloads for this category's photos.
// Toggle per-category download permission (#640). Event-specific only.
const downloadToggleMutation = useMutationWithToast({
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
@@ -85,6 +88,32 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
});
// Per-event order override (#782). Sends the full ordered id list; the backend
// pins it for this gallery only. Up/down buttons match the invoice line-item
// convention (no drag-and-drop dependency).
const reorderMutation = useMutationWithToast({
mutationFn: (orderedIds: number[]) => categoriesService.reorderCategories(eventId, orderedIds),
invalidateKeys: [['event-categories', eventId]],
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
});
// Revert this gallery to the global default order.
const resetMutation = useMutationWithToast({
mutationFn: () => categoriesService.resetEventOrder(eventId),
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.orderReset', 'Reverted to the default order'),
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
});
const handleMove = (index: number, dir: -1 | 1) => {
const target = index + dir;
if (target < 0 || target >= ordered.length) return;
const next = [...ordered];
[next[index], next[target]] = [next[target], next[index]];
setOrdered(next); // optimistic — instant feedback
reorderMutation.mutate(next.map((c) => c.id));
};
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
@@ -105,6 +134,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
heroMutation.mutate({ categoryId, photoId: null });
};
const busy = reorderMutation.isPending || resetMutation.isPending;
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
@@ -115,23 +146,38 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
{!addingModal.isOpen && (
<Button
variant="outline"
size="sm"
onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
</Button>
)}
<div className="flex justify-between items-center gap-2">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.galleryOrder', 'Gallery order')}</h3>
<div className="flex items-center gap-2">
{isCustomised && (
<Button
variant="outline"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={busy}
leftIcon={<RotateCcw className="w-3 h-3" />}
>
{t('categories.resetToDefault', 'Reset to default')}
</Button>
)}
{!addingModal.isOpen && (
<Button
variant="outline"
size="sm"
onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
</Button>
)}
</div>
</div>
{/* Hint about hero photo fallback */}
{/* Explain the two ordering layers */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
{t('categories.categoryHeroHint')}
{isCustomised
? t('categories.orderCustomisedHint', 'This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).')
: t('categories.orderDefaultHint', 'Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}
</p>
{/* Add new category form */}
@@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
t('common.add')
)}
{createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.add')}
</Button>
<Button
variant="outline"
@@ -171,14 +213,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div>
)}
{/* Event categories list */}
{eventCategories.length === 0 ? (
{/* Combined, reorderable category list (globals + event-specific) */}
{ordered.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('categories.noEventSpecificCategories')}
</p>
) : (
<div className="space-y-2">
{eventCategories.map((category) => {
{ordered.map((category, index) => {
const heroPhoto = category.hero_photo_id
? photos.find(p => p.id === category.hero_photo_id)
: null;
@@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Reorder controls (#782). The gallery renders categories in
this order; changes here override the global default for
this event only. */}
<div className="flex flex-col -space-y-1">
<button
onClick={() => handleMove(index, -1)}
disabled={index === 0 || busy}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveUp', 'Move up')}
aria-label={t('categories.moveUp', 'Move up')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleMove(index, 1)}
disabled={index === ordered.length - 1 || busy}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveDown', 'Move down')}
aria-label={t('categories.moveDown', 'Move down')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{/* Hero photo thumbnail */}
<button
onClick={() => setHeroPickerCategoryId(category.id)}
@@ -207,49 +272,56 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
)}
</button>
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
{category.is_global && (
<span className="flex-shrink-0 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400">
{t('categories.sharedBadge', 'Shared')}
</span>
)}
</div>
<div className="flex items-center gap-1">
{/* Per-category downloads toggle (#640). Green DownloadCloud
icon when on, struck-through outline when off. The
event-level `allow_downloads` AND's with this — if the
whole event has downloads off, this toggle is cosmetic. */}
<button
onClick={() => downloadToggleMutation.mutate({
category,
allow: category.allow_downloads === false,
})}
className={`p-1 transition-colors ${
category.allow_downloads === false
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
}`}
title={
category.allow_downloads === false
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
}
disabled={downloadToggleMutation.isPending}
>
{downloadToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : category.allow_downloads === false ? (
<Download className="w-3 h-3" />
) : (
<DownloadCloud className="w-3 h-3" />
)}
</button>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
{/* Download toggle + delete apply to event-specific categories
only. Global categories are managed in Settings. */}
{!category.is_global && (
<>
<button
onClick={() => downloadToggleMutation.mutate({
category,
allow: category.allow_downloads === false,
})}
className={`p-1 transition-colors ${
category.allow_downloads === false
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
}`}
title={
category.allow_downloads === false
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
}
disabled={downloadToggleMutation.isPending}
>
{downloadToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : category.allow_downloads === false ? (
<Download className="w-3 h-3" />
) : (
<DownloadCloud className="w-3 h-3" />
)}
</button>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</>
)}
</div>
</div>
);
@@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div>
)}
{/* Show available global categories */}
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
<div className="space-y-2">
{categories
.filter(cat => cat.is_global)
.map(cat => {
const heroPhoto = cat.hero_photo_id
? photos.find(p => p.id === cat.hero_photo_id)
: null;
return (
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
<button
onClick={() => setHeroPickerCategoryId(cat.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')}
>
{heroPhoto ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={cat.name}
className="w-full h-full object-cover"
/>
) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-accent" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
</div>
);
})}
</div>
</div>
{/* Hint about hero photo fallback */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
{t('categories.categoryHeroHint')}
</p>
{/* Hero Photo Picker Modal */}
{heroPickerCategoryId !== null && (
@@ -317,7 +358,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => {
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
const currentCategory = ordered.find(c => c.id === heroPickerCategoryId);
const isSelected = photo.id === currentCategory?.hero_photo_id;
return (
<div
@@ -337,7 +378,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
/>
</div>
{isSelected && (
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<div className="absolute top-2 right-2 bg-accent-dark text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
@@ -352,7 +393,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div>
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
{ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
<Button
variant="outline"
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Plus, X } from 'lucide-react';
import { Button, Input, Loading } from '../common';
import { eventTypesService, EventType } from '../../services/eventTypes.service';
interface Props {
onDone: () => void;
}
// One editable row of the wizard's event-type list. Existing rows carry the
// catalog id; rows added in the wizard have no id until Continue POSTs them.
interface RowState {
id?: number;
name: string;
slug_prefix: string;
emoji: string;
}
const normalizeSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// First-run event-types step (#800). Shown once, during the setup wizard —
// the only window in which the seeded SYSTEM types may be deleted (nothing
// references them yet; the backend re-locks them when the wizard finishes).
// Deliberately lean: name + URL prefix only. Icons, themes and ordering are
// tunable later in Settings → Event Types.
export const SetupEventTypesStep: React.FC<Props> = ({ onDone }) => {
const { t } = useTranslation();
const [rows, setRows] = useState<RowState[] | null>(null);
const [original, setOriginal] = useState<Map<number, EventType>>(new Map());
const [deletedIds, setDeletedIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const { isLoading, isError } = useQuery({
queryKey: ['setup-event-types'],
queryFn: async () => {
const types = await eventTypesService.getEventTypes();
// Initialize once — a re-run (remount) must not clobber in-progress edits.
setOriginal((prev) => (prev.size > 0 ? prev : new Map(types.map((et) => [et.id, et]))));
setRows((prev) => prev ?? types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
return types;
},
staleTime: Infinity,
});
const setRow = (index: number, patch: Partial<RowState>) => {
setRows((prev) => (prev ? prev.map((r, i) => (i === index ? { ...r, ...patch } : r)) : prev));
};
const removeRow = (index: number) => {
// No side effects inside the setRows updater — StrictMode double-invokes
// updaters, which would enqueue the same id twice (one DELETE 404s and
// shows a false "could not save" warning).
const row = rows?.[index];
if (!row) return;
if (row.id !== undefined) {
setDeletedIds((ids) => (ids.includes(row.id!) ? ids : [...ids, row.id!]));
}
setRows((prev) => (prev ? prev.filter((_, i) => i !== index) : prev));
};
const addRow = () => {
setRows((prev) => (prev ? [...prev, { name: '', slug_prefix: '', emoji: '📷' }] : prev));
};
// Apply the diff, then advance. Ordering matters twice over: deletes first
// frees a default's slug for a rename/re-create ("replace Wedding with my
// own 'wedding'"), but deleting everything BEFORE a replacement exists could
// empty the catalog if the creation then fails. So: when at least one
// existing row is kept the catalog can never go empty → delete first; when
// the user replaces ALL types → create first and only delete once at least
// one replacement actually persisted. (The backend additionally refuses
// deleting the last remaining type.) Best-effort like the other wizard
// steps — a partial failure warns but never traps the user; everything here
// is editable later in Settings → Event Types.
const handleContinue = async () => {
if (!rows) return;
const kept = rows.filter((r) => r.name.trim() && r.slug_prefix.trim());
if (kept.length === 0) {
toast.error(t('setup.eventTypes.atLeastOne'));
return;
}
setSaving(true);
let failures = 0;
let deleteFailures = 0;
let createdOk = 0;
const applyDeletes = async () => {
for (const id of deletedIds) {
try {
await eventTypesService.deleteEventType(id);
} catch {
deleteFailures += 1;
}
}
};
const applyCreatesAndUpdates = async () => {
for (const row of kept) {
try {
if (row.id !== undefined) {
const before = original.get(row.id);
const updates: { name?: string; slug_prefix?: string } = {};
if (before && row.name.trim() !== before.name) updates.name = row.name.trim();
if (before && row.slug_prefix !== before.slug_prefix) updates.slug_prefix = row.slug_prefix;
if (Object.keys(updates).length > 0) {
await eventTypesService.updateEventType(row.id, updates);
}
} else {
await eventTypesService.createEventType({
name: row.name.trim(),
slug_prefix: row.slug_prefix,
emoji: row.emoji,
});
createdOk += 1;
}
} catch {
failures += 1;
}
}
};
const keptExisting = kept.filter((r) => r.id !== undefined).length;
if (keptExisting > 0) {
await applyDeletes();
await applyCreatesAndUpdates();
} else {
await applyCreatesAndUpdates();
if (deletedIds.length > 0 && createdOk === 0) {
// Every replacement failed — deleting now would empty the catalog.
// Keep the seeded types and stay on the step.
setSaving(false);
toast.error(t('setup.eventTypes.atLeastOne'));
return;
}
await applyDeletes();
}
// A failed DELETE must not slip past this step: system types are only
// deletable inside this window, so once the wizard finishes the request
// can never be retried. Reload the live catalog and stay for a retry.
if (deleteFailures > 0) {
try {
const types = await eventTypesService.getEventTypes();
setOriginal(new Map(types.map((et) => [et.id, et])));
setRows(types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
} catch { /* keep the local rows if the reload fails */ }
setDeletedIds([]);
setSaving(false);
toast.error(t('setup.eventTypes.deleteFailed'));
return;
}
setSaving(false);
if (failures > 0) toast.warn(t('setup.eventTypes.saveFailed'));
onDone();
};
if (isLoading || rows === null) {
return isError ? (
// Catalog unreadable — don't trap the user; the defaults stay seeded and
// remain editable later in Settings → Event Types.
<div className="space-y-6">
<p className="text-sm text-neutral-600">{t('setup.eventTypes.loadFailed')}</p>
<Button type="button" variant="primary" size="lg" className="w-full" onClick={onDone}>
{t('setup.continue')}
</Button>
</div>
) : (
<Loading />
);
}
return (
<div className="space-y-6">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.eventTypes.intro')}
</p>
<div className="space-y-2">
{rows.map((row, index) => (
<div key={row.id ?? `new-${index}`} className="flex items-center gap-2">
<span className="w-8 text-center text-xl flex-shrink-0" aria-hidden="true">{row.emoji}</span>
<div className="flex-1 min-w-0">
<Input
value={row.name}
onChange={(e) => setRow(index, { name: e.target.value })}
placeholder={t('setup.eventTypes.namePlaceholder')}
aria-label={t('setup.eventTypes.nameLabel')}
/>
</div>
<div className="w-32 flex-shrink-0">
<Input
value={row.slug_prefix}
onChange={(e) => setRow(index, { slug_prefix: normalizeSlug(e.target.value) })}
placeholder={t('setup.eventTypes.slugPlaceholder')}
aria-label={t('setup.eventTypes.slugLabel')}
/>
</div>
<button
type="button"
onClick={() => removeRow(index)}
className="flex-shrink-0 p-2 rounded-lg text-neutral-400 hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label={t('common.delete', 'Delete')}
title={t('common.delete', 'Delete')}
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addRow}
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{t('setup.eventTypes.add')}</span>
</button>
<p className="text-xs text-neutral-500">{t('setup.eventTypes.hint')}</p>
<Button
type="button"
variant="primary"
size="lg"
isLoading={saving}
className="w-full"
onClick={handleContinue}
>
{t('setup.continue')}
</Button>
</div>
);
};
SetupEventTypesStep.displayName = 'SetupEventTypesStep';
@@ -16,11 +16,13 @@
* POST .../slideshow/{generate,disable}.
*/
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
import { Button, Card } from '../common';
import { eventsService } from '../../services/events.service';
import { categoriesService } from '../../services/categories.service';
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
import { SlideshowStyleFields } from './SlideshowStyleFields';
@@ -35,6 +37,8 @@ export interface SlideshowSettingsCardProps {
show_transition_ms?: number;
show_watermark?: boolean | null;
show_colorfilter?: string;
show_order?: string;
show_category_id?: number | null;
};
onChanged?: () => void;
}
@@ -52,6 +56,8 @@ function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): Slide
transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms,
watermark: watermarkMode(initial.show_watermark),
colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter,
order: (initial.show_order as SlideshowStyle['order']) ?? DEFAULT_SLIDESHOW_STYLE.order,
category_id: initial.show_category_id ?? null,
};
}
@@ -68,6 +74,14 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : '';
// Event categories for the slideshow content filter (#202). Global + this
// event's own categories; empty for events without any → picker hides.
const { data: categories = [] } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
staleTime: 60_000,
});
const generate = async () => {
setBusy(true);
try {
@@ -129,6 +143,8 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
// is global-only (Settings → Slideshow); we only send the mode here.
show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on',
show_colorfilter: style.colorfilter,
show_order: style.order,
show_category_id: style.category_id,
});
toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved'));
onChanged?.();
@@ -208,7 +224,7 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
{/* Live style settings */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<SlideshowStyleFields value={style} onChange={setStyle} />
<SlideshowStyleFields value={style} onChange={setStyle} categories={categories} />
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')}
@@ -15,12 +15,17 @@ import {
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
SLIDESHOW_WATERMARK_MODES,
SLIDESHOW_ORDERS,
type SlideshowStyle,
} from '../../services/slideshow.service';
import type { PhotoCategory } from '../../services/categories.service';
export interface SlideshowStyleFieldsProps {
value: SlideshowStyle;
onChange: (next: SlideshowStyle) => void;
/** Event categories for the content filter (#202). Omitted/empty → the
* category picker is hidden (e.g. events without any categories). */
categories?: PhotoCategory[];
}
const inputClass =
@@ -29,7 +34,7 @@ const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange }) => {
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange, categories = [] }) => {
const { t } = useTranslation();
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
@@ -92,6 +97,39 @@ export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ valu
</select>
</div>
{/* Play order + content filter (#202) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('slideshow.orderLabel', 'Play order')}</label>
<select
value={value.order}
onChange={(e) => set({ order: e.target.value as SlideshowStyle['order'] })}
className={inputClass}
>
{SLIDESHOW_ORDERS.map((o) => (
<option key={o} value={o}>
{t(`slideshow.order.${o}`, o === 'random' ? 'Random (shuffle)' : 'Chronological')}
</option>
))}
</select>
</div>
{categories.length > 0 && (
<div>
<label className={labelClass}>{t('slideshow.categoryLabel', 'Show only category')}</label>
<select
value={value.category_id ?? ''}
onChange={(e) => set({ category_id: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
className={inputClass}
>
<option value="">{t('slideshow.categoryAll', 'All photos')}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
</div>
{/* Watermark — MODE only. The look (logo/position/opacity/style/size)
lives in Settings → Slideshow, so it isn't duplicated here. */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
@@ -154,39 +154,45 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
{/* Category and Feedback Filters */}
<div className="space-y-3">
{/* Categories Row */}
{categories && categories.length > 0 && (
{/* Categories + desktop feedback row. Rendered whenever EITHER part
has content: the desktop feedback chips must not depend on the
(optional) categories existing, or category-less galleries show
no feedback filter at all on desktop (#802 — the lg:hidden
fallback block below only covers mobile/tablet). */}
{((categories && categories.length > 0) || (feedbackEnabled && !!onFilterChange)) && (
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
{/* Categories: keep in a horizontal scroll container */}
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
<div className="flex items-center gap-2 min-w-max">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return (
<Button
key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
{categories && categories.length > 0 && (
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
<div className="flex items-center gap-2 min-w-max">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return (
<Button
key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
</div>
</div>
</div>
)}
{/* Desktop: compact horizontal feedback filter with headline (icons only) */}
{feedbackEnabled && onFilterChange && (
@@ -244,7 +250,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</div>
)}
<p className="text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto">
{/* Without categories this row only carries desktop content (the
chips are lg-only; mobile has its own block below), so hide
the count below lg to keep the mobile layout unchanged. */}
<p className={`text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto ${categories && categories.length > 0 ? '' : 'hidden lg:block'}`}>
{photoCount} {t('common.media', 'media')}
</p>
</div>
@@ -0,0 +1,72 @@
/**
* Regression coverage for #802: the desktop feedback-filter chips
* (All / Likes / Saved / Rated / Commented) were nested inside the
* categories row, so a gallery WITHOUT photo categories (the default)
* rendered no feedback filter at all on desktop — the standalone
* fallback block is lg:hidden (mobile/tablet only). These tests pin
* that both chip groups exist in the DOM regardless of categories.
*/
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PhotoFilterBar } from '../PhotoFilterBar';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: unknown) =>
typeof fallback === 'string' ? fallback : _key,
i18n: { language: 'en' }
})
};
});
const baseProps = {
categories: [] as Array<{ id: number; name: string; slug: string }>,
photos: [] as never[],
selectedCategoryId: null,
onCategoryChange: vi.fn(),
searchTerm: '',
onSearchChange: vi.fn(),
sortBy: 'date' as const,
onSortChange: vi.fn(),
photoCount: 0,
};
describe('PhotoFilterBar feedback chips (#802)', () => {
it('renders both chip groups (desktop lg:flex + mobile lg:hidden) with NO categories', () => {
render(
<PhotoFilterBar
{...baseProps}
feedbackEnabled
currentFilter="all"
onFilterChange={vi.fn()}
/>
);
// Two groups: the desktop row variant and the mobile fallback. Before
// the fix, only the mobile one rendered when categories were empty,
// leaving desktop with no feedback filter at all.
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
});
it('still renders both chip groups when categories exist', () => {
render(
<PhotoFilterBar
{...baseProps}
categories={[{ id: 1, name: 'Ceremony', slug: 'ceremony' }]}
photos={[{ id: 1, category_id: 1 } as never]}
feedbackEnabled
currentFilter="all"
onFilterChange={vi.fn()}
/>
);
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
});
it('renders no chips when feedback is disabled', () => {
render(<PhotoFilterBar {...baseProps} />);
expect(screen.queryByText('Feedback Filter')).toBeNull();
});
});
+36
View File
@@ -947,6 +947,15 @@
"coverPhotoRemoved": "Titelbild entfernt",
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
"categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"failedToReorder": "Kategorie-Reihenfolge konnte nicht aktualisiert werden",
"galleryOrder": "Galerie-Reihenfolge",
"resetToDefault": "Auf Standard zurücksetzen",
"orderReset": "Auf Standardreihenfolge zurückgesetzt",
"orderCustomisedHint": "Diese Galerie verwendet eine eigene Reihenfolge. Zurücksetzen, um der globalen Standardreihenfolge zu folgen (Einstellungen → Fotokategorien).",
"orderDefaultHint": "Mit den Pfeilen die Reihenfolge für diese Galerie festlegen. Andernfalls gilt die globale Standardreihenfolge (Einstellungen → Fotokategorien).",
"sharedBadge": "Geteilt",
"downloadsEnabled": "Downloads für diese Kategorie aktiviert",
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
@@ -2411,6 +2420,7 @@
"channelBeta": "Beta",
"beta": "BETA",
"viewReleaseNotes": "Versionshinweise anzeigen",
"viewOnGithub": "PicPeak auf GitHub ansehen",
"updateAvailableShort": "v{{version}} verfügbar",
"upToDate": "Alles aktuell",
"updateNow": "Jetzt aktualisieren",
@@ -3475,6 +3485,13 @@
"cool": "Kühl",
"vignette": "Vignette"
},
"orderLabel": "Reihenfolge",
"order": {
"chronological": "Chronologisch",
"random": "Zufällig (mischen)"
},
"categoryLabel": "Nur Kategorie zeigen",
"categoryAll": "Alle Fotos",
"watermarkToggle": "Logo-Wasserzeichen anzeigen",
"watermarkDescription": "Blendet ein weißes, halbtransparentes Logo in einer Ecke ein (wie ein Senderlogo im TV).",
"watermarkSourceLabel": "Logo",
@@ -3594,6 +3611,20 @@
"finish": "Einrichtung abschließen",
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
},
"eventTypes": {
"subtitle": "Welche Veranstaltungen fotografieren Sie?",
"intro": "Veranstaltungsarten ordnen Ihre Galerien — jede Art erhält ein eigenes URL-Präfix und Standard-Theme. Die Vorschläge unten sind nur ein Startpunkt: Benennen Sie sie um, entfernen Sie Unnötiges oder fügen Sie eigene hinzu. Nur jetzt können die mitgelieferten Arten gelöscht werden; später lassen sie sich nur umbenennen oder deaktivieren.",
"nameLabel": "Anzeigename",
"namePlaceholder": "z.B. Familienshooting",
"slugLabel": "URL-Präfix",
"slugPlaceholder": "z.B. familie",
"add": "Veranstaltungsart hinzufügen",
"hint": "Das URL-Präfix erscheint in Galerie-Links (z.B. familie-mueller-2025-06-01). Symbole, Themes und Reihenfolge können Sie später unter Einstellungen → Veranstaltungsarten anpassen.",
"atLeastOne": "Behalten Sie mindestens eine Veranstaltungsart — jede Galerie braucht eine.",
"loadFailed": "Veranstaltungsarten konnten nicht geladen werden — Sie können sie später unter Einstellungen → Veranstaltungsarten anpassen.",
"saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen.",
"deleteFailed": "Eine Löschung ist fehlgeschlagen — die Liste wurde neu geladen. Mitgelieferte Arten können nur hier gelöscht werden; versuchen Sie es erneut oder fahren Sie mit ihnen fort."
},
"community": {
"subtitle": "Alles bereit",
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass du es ausprobierst.",
@@ -5216,6 +5247,11 @@
"crm_invoices_late_fee_label": {
"label": "Bezeichnung Mahngebühr"
},
"crm_invoices_vat_note_text": {
"label": "MwSt.- / Freitext-Hinweis auf Rechnungen",
"placeholder": "z. B. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
"help": "Wird direkt unter der MwSt.-Zeile auf jeder Rechnungs-PDF gedruckt. Leer lassen zum Ausblenden. Bitte den genauen Wortlaut mit deinem Steuerberater abstimmen."
},
"crm_invoices_skonto_percent_default": {
"label": "Standard-Skonto %"
},
+36
View File
@@ -494,6 +494,15 @@
"coverPhotoRemoved": "Cover photo removed",
"failedToSetCoverPhoto": "Failed to set cover photo",
"categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.",
"moveUp": "Move up",
"moveDown": "Move down",
"failedToReorder": "Failed to update category order",
"galleryOrder": "Gallery order",
"resetToDefault": "Reset to default",
"orderReset": "Reverted to the default order",
"orderCustomisedHint": "This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).",
"orderDefaultHint": "Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).",
"sharedBadge": "Shared",
"downloadsEnabled": "Downloads enabled for this category",
"downloadsDisabled": "Downloads disabled for this category",
"enableDownloadsTitle": "Click to enable downloads for this category",
@@ -1987,6 +1996,7 @@
"channelBeta": "Beta",
"beta": "BETA",
"viewReleaseNotes": "View Release Notes",
"viewOnGithub": "View PicPeak on GitHub",
"updateAvailableShort": "v{{version}} available",
"upToDate": "You're up to date",
"updateNow": "Update Now",
@@ -3490,6 +3500,20 @@
"finish": "Finish setup",
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
},
"eventTypes": {
"subtitle": "Which events do you photograph?",
"intro": "Event types organize your galleries — each one gets its own URL prefix and default theme. The suggestions below are just a starting point: rename them, remove what you don't need, or add your own. This is the only time the built-in types can be deleted; later they can only be renamed or deactivated.",
"nameLabel": "Display name",
"namePlaceholder": "e.g. Family Shoot",
"slugLabel": "URL prefix",
"slugPlaceholder": "e.g. family",
"add": "Add event type",
"hint": "The URL prefix appears in gallery links (e.g. family-smith-2025-06-01). Icons, themes and order can be tuned later in Settings → Event Types.",
"atLeastOne": "Keep at least one event type — every gallery needs one.",
"loadFailed": "Could not load the event types — you can adjust them later in Settings → Event Types.",
"saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types.",
"deleteFailed": "A deletion failed — the list has been reloaded. Built-in types can only be deleted here, so retry or continue with them kept."
},
"community": {
"subtitle": "You're all set",
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
@@ -3603,6 +3627,13 @@
"cool": "Cool",
"vignette": "Vignette"
},
"orderLabel": "Play order",
"order": {
"chronological": "Chronological",
"random": "Random (shuffle)"
},
"categoryLabel": "Show only category",
"categoryAll": "All photos",
"watermarkToggle": "Show logo watermark",
"watermarkDescription": "Overlay a white, semi-transparent logo in a corner (like a TV station ident).",
"watermarkSourceLabel": "Logo",
@@ -5214,6 +5245,11 @@
"crm_invoices_late_fee_label": {
"label": "Late fee label"
},
"crm_invoices_vat_note_text": {
"label": "VAT / free-text note on invoices",
"placeholder": "e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
"help": "Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor."
},
"crm_invoices_skonto_percent_default": {
"label": "Skonto rate (default %)"
},
+37 -19
View File
@@ -11,6 +11,7 @@ import { setupService } from '../services/setup.service';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types';
@@ -67,7 +68,7 @@ export const SetupPage: React.FC = () => {
staleTime: Infinity,
});
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config' | 'community'>('token');
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'community'>('token');
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -241,19 +242,25 @@ export const SetupPage: React.FC = () => {
toast.warn(t('setup.featuresSaveFailed'));
} finally {
setIsSavingFeatures(false);
// If the chosen features need config the wizard can collect (invoicing,
// email), go to the config step; otherwise enter the app.
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
// Both the config branch and the no-config path end on the final
// community/thank-you step (#732), whose Finish button enters the app.
setStep(needsConfig ? 'config' : 'community');
// Event types come next (#800) — the wizard is the one window in which
// the seeded defaults can be freely renamed or deleted, because nothing
// (events, quotes, reminder mails) references them yet.
setStep('eventTypes');
}
};
// After the event-types step: if the chosen features need config the wizard
// can collect (invoicing, email), go to the config step; otherwise skip to
// the final community/thank-you step (#732), whose Finish enters the app.
const continueAfterEventTypes = () => {
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
setStep(needsConfig ? 'config' : 'community');
};
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
return (
@@ -278,13 +285,15 @@ export const SetupPage: React.FC = () => {
? t('setup.tokenStepSubtitle')
: step === 'account'
? t('setup.accountStepSubtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: step === 'community'
? t('setup.community.subtitle')
: t('setup.usageSubtitle')}
: step === 'eventTypes'
? t('setup.eventTypes.subtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: step === 'community'
? t('setup.community.subtitle')
: t('setup.usageSubtitle')}
</p>
{(step === 'token' || step === 'account' || step === 'usage') && (
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
@@ -509,6 +518,8 @@ export const SetupPage: React.FC = () => {
{t('setup.back')}
</Button>
</div>
) : step === 'eventTypes' ? (
<SetupEventTypesStep onDone={continueAfterEventTypes} />
) : step === 'config' ? (
<SetupConfigStep
selectedFeatures={selectedFeatures}
@@ -546,7 +557,14 @@ export const SetupPage: React.FC = () => {
variant="primary"
size="lg"
className="w-full"
onClick={() => navigate('/admin/dashboard', { replace: true })}
onClick={async () => {
// One-way marker: re-locks the seeded system event types
// (#800). Best-effort — a failure must not trap the user on
// the thank-you screen, and the flag re-arms nothing risky
// (the delete window also requires zero usage server-side).
try { await setupService.completeSetup(); } catch { /* best-effort */ }
navigate('/admin/dashboard', { replace: true });
}}
rightIcon={<ArrowRight className="w-4 h-4" />}
>
{t('setup.community.finish')}
@@ -182,6 +182,18 @@ export const CreateEventPage: React.FC = () => {
[eventTypes]
);
// The hardcoded initial form value ('wedding') may not exist in the live
// catalog — the setup wizard can rename or delete the defaults (#800), and
// the backend now rejects unknown slugs. Snap to the first active type; a
// user-picked value is always in the list, so this never fights the user.
useEffect(() => {
if (!availableEventTypes.length) return;
if (!availableEventTypes.some(t => t.value === formData.event_type)) {
setFormData(prev => ({ ...prev, event_type: availableEventTypes[0].value }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [availableEventTypes, formData.event_type]);
// Fetch default settings
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
@@ -29,6 +29,8 @@ const SETTING_KEYS = [
'crm_quotes_tos_url',
'crm_invoices_qr_enabled',
'crm_invoice_round_total',
// Free-text VAT/legal note printed under the MwSt. line on invoice PDFs (#794).
'crm_invoices_vat_note_text',
'crm_invoices_reminders_enabled',
'crm_invoices_reminder_first_days',
'crm_invoices_reminder_second_days',
@@ -249,6 +251,25 @@ export const CrmSettingsPage: React.FC = () => {
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
{checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')}
{/* Free-text VAT / legal note (#794) — printed directly under the MwSt.
line on every invoice PDF. Data-driven: the admin types the exact
wording (Austrian Kleinunternehmer, German §19, reverse-charge, …). */}
<div className="mt-3">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_invoices_vat_note_text.label', 'VAT / free-text note on invoices')}
</label>
<textarea
value={values.crm_invoices_vat_note_text ?? ''}
onChange={(e) => setVal('crm_invoices_vat_note_text', e.target.value)}
rows={2}
placeholder={t('crmSettings.crm_invoices_vat_note_text.placeholder', 'e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).') as string}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('crmSettings.crm_invoices_vat_note_text.help', 'Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor.')}
</p>
</div>
{/* Reminder TIMING: owned by the Invoice dunning workflow when the
engine is live (callout); otherwise the legacy schedule controls. The
late-fee math below is configured here in both cases — it's the fee
+16 -2
View File
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS: SlideshowSettings = {
transition: 'crossfade',
transition_ms: 800,
colorfilter: 'none',
order: 'chronological',
fit: 'cover',
watermark: null,
};
@@ -65,6 +66,17 @@ function watermarkCorner(position: string): React.CSSProperties {
type Phase = 'splash' | 'running' | 'ended';
// FisherYates shuffle for the 'random' play order (#202). Used once on the
// initial photo set; live-appended uploads keep landing at the end.
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
export function SlideshowPage() {
const { slug = '', token = '' } = useParams<{ slug: string; token: string }>();
const { t } = useTranslation();
@@ -159,13 +171,15 @@ export function SlideshowPage() {
storeGalleryToken(slug, session.token);
setActiveGallerySlug(slug);
setEventName(session.event.event_name || '');
setSettings(session.settings || DEFAULT_SETTINGS);
const settings = session.settings || DEFAULT_SETTINGS;
setSettings(settings);
// Load the list and DECODE the first slide (and the next) before we flip
// to running, so playback starts on an already-rasterised image instead
// of struggling on the first transition.
const data = await galleryService.getGalleryPhotos(slug);
const list = data.photos || [];
// 'random' shuffles the initial set once; new uploads still append (#202).
const list = settings.order === 'random' ? shuffle(data.photos || []) : (data.photos || []);
setPhotos(list);
await preloadDecode(list[0]);
void preloadDecode(list[1]);
@@ -10,6 +10,12 @@ export interface PhotoCategory {
// Per-category download permission (#640). Defaults true (server-side) so
// categories created before migration 135 keep working.
allow_downloads?: boolean;
// Global default sort order (#782). Backfilled from the previous alphabetical
// order on migration, so existing galleries don't reshuffle.
display_order?: number;
// Per-event override position (#782). Non-null on the /event/:id response when
// this gallery has customised its order; null means it follows the default.
override_position?: number | null;
created_at: string;
}
@@ -61,5 +67,31 @@ export const categoriesService = {
// Delete a category
async deleteCategory(id: number): Promise<void> {
await api.delete(`/admin/categories/${id}`);
},
// Set a per-event order override (#782). Sends the full ordered id list for
// this event — globals + event-specific — and returns the resolved order.
// Overrides the global default for this gallery only.
async reorderCategories(eventId: number, orderedIds: number[]): Promise<PhotoCategory[]> {
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder', {
event_id: eventId,
orderedIds
});
return response.data;
},
// Clear an event's override — revert this gallery to the global default order.
async resetEventOrder(eventId: number): Promise<PhotoCategory[]> {
const response = await api.delete<PhotoCategory[]>(`/admin/categories/reorder/${eventId}`);
return response.data;
},
// Set the GLOBAL default order for shared categories (#782). Applies to every
// gallery that hasn't set its own override.
async reorderGlobalCategories(orderedIds: number[]): Promise<PhotoCategory[]> {
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder-global', {
orderedIds
});
return response.data;
}
};
+2
View File
@@ -158,6 +158,8 @@ export const eventsService = {
show_transition_ms?: number;
show_watermark?: boolean | null;
show_colorfilter?: string;
show_order?: string;
show_category_id?: number | null;
}
): Promise<Record<string, unknown>> {
const response = await api.patch(`/admin/events/${id}/slideshow`, settings);
+7
View File
@@ -38,4 +38,11 @@ export const setupService = {
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
return response.data;
},
// One-way wizard-finish marker (authenticated — runs after the admin
// exists). While unset, the wizard's event-types step may delete the
// seeded system types; afterwards they are permanently protected.
async completeSetup(): Promise<void> {
await api.post('/setup/complete');
},
};
@@ -18,6 +18,9 @@ export const SLIDESHOW_WATERMARK_STYLES: SlideshowWatermarkStyle[] = ['white', '
// (admin Settings → Slideshow); 'on'/'off' = explicit override.
export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off';
export const SLIDESHOW_WATERMARK_MODES: SlideshowWatermarkMode[] = ['inherit', 'on', 'off'];
// Play order (#202): 'chronological' = upload order; 'random' = client shuffle.
export type SlideshowOrder = 'chronological' | 'random';
export const SLIDESHOW_ORDERS: SlideshowOrder[] = ['chronological', 'random'];
export const SLIDESHOW_TRANSITIONS: SlideshowTransition[] = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
@@ -37,6 +40,9 @@ export interface SlideshowStyle {
transition_ms: number;
watermark: SlideshowWatermarkMode;
colorfilter: SlideshowColorFilter;
// Play order + optional category filter (#202). category_id null = all photos.
order: SlideshowOrder;
category_id: number | null;
}
export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
@@ -45,6 +51,8 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
transition_ms: 800,
watermark: 'inherit',
colorfilter: 'none',
order: 'chronological',
category_id: null,
};
// Global slideshow defaults (admin Settings → Slideshow). The single source of
@@ -81,6 +89,10 @@ export interface SlideshowSettings {
transition: SlideshowTransition;
transition_ms: number;
colorfilter: SlideshowColorFilter;
// Play order the kiosk applies (#202): 'random' shuffles client-side so
// live-appended uploads keep working. The category filter is enforced
// server-side, so it isn't echoed here.
order: SlideshowOrder;
fit: SlideshowFit;
watermark: SlideshowWatermark | null;
}
+6 -1
View File
@@ -10,5 +10,10 @@
* notes for the running version (#566) and by the update-available
* indicator to link to the upgrade target's notes.
*/
/** Repository home on GitHub. Single source of truth for the org URL so
* links (release notes, the admin "view on GitHub" button, #778) don't
* each hardcode it. */
export const repoUrl = 'https://github.com/PicPeak/picpeak';
export const githubReleaseUrl = (version: string): string =>
`https://github.com/PicPeak/picpeak/releases/tag/v${version}`;
`${repoUrl}/releases/tag/v${version}`;