diff --git a/.env.example b/.env.example
index 656ffeb1..197fbc56 100644
--- a/.env.example
+++ b/.env.example
@@ -68,6 +68,16 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
+# Static HTML title + description used for social link previews when the
+# fetcher doesn't trigger the per-event OG endpoint — most notably the
+# WhatsApp Business API and various 3rd-party preview-service caches
+# (#521). Set these to your brand so link previews aren't generic.
+# Substituted into index.html at frontend-container start, so changes
+# take effect on the next `docker compose up -d frontend` — no rebuild
+# required.
+BRAND_TITLE=PicPeak
+BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
+
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
@@ -97,12 +107,6 @@ UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
-# Runtime user mapping for Docker (optional)
-# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
-# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
-PUID=1001
-PGID=1001
-
# Analytics (Optional - Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 4e273b96..bf98c108 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -45,6 +45,13 @@ env:
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
# working on forks regardless of the owner's name casing.
+# Default GITHUB_TOKEN to read-only at the workflow level. Each job that
+# needs to publish to GHCR sets `packages: write` explicitly. This keeps
+# the rest of the workflow (and any future steps) from inheriting unneeded
+# privileges (CKV2_GHA_1).
+permissions:
+ contents: read
+
jobs:
# -----------------------------------------------------------------------------
# Backend: per-arch build, then merge into a multi-arch manifest
@@ -62,6 +69,11 @@ jobs:
permissions:
contents: read
packages: write
+ # Trivy uploads its SARIF to the Security tab from this job — see
+ # the "Run Trivy" step below. Scanning per-arch by digest (#476)
+ # is reliable; scanning the multi-arch index by tag from the
+ # merge-* job was not.
+ security-events: write
steps:
- name: Checkout code
@@ -146,13 +158,58 @@ jobs:
if-no-files-found: error
retention-days: 1
+ # Per-arch vulnerability scan (#476). Scanning the multi-arch
+ # manifest from the merge-* job by tag is unreliable — Trivy's
+ # remote resolver crashes intermittently with "no child with
+ # platform linux/amd64 in index". The fix is to scan each leg
+ # by its single-platform digest right here, where it just landed
+ # in GHCR. Tag pinned (was @master) so the action + bundled
+ # Trivy binary don't float between runs.
+ #
+ # exit-code is left unset (=0) for now: Trivy reports findings
+ # to the Security tab but doesn't fail the build. Flipping that
+ # to '1' to actually gate CI is a deliberate follow-up — needs an
+ # audit pass first so the next beta build doesn't surprise red.
+ - name: Run Trivy vulnerability scanner (per-arch, by digest)
+ if: steps.push-decision.outputs.push == 'true'
+ uses: aquasecurity/trivy-action@v0.36.0
+ env:
+ # docker/build-push-action wraps every push in an OCI index
+ # (carries the SLSA provenance attestation alongside the
+ # actual image). Trivy's remote backend defaults to
+ # linux/amd64 regardless of host arch when resolving an
+ # index, which makes the arm64 leg crash with "no child
+ # with platform linux/amd64". Telling Trivy which child to
+ # scan keeps the provenance attestation intact and fixes
+ # the resolver crash. Pin to matrix.platform so each leg
+ # scans its own arch.
+ TRIVY_PLATFORM: ${{ matrix.platform }}
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
+ format: 'sarif'
+ output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
+ severity: 'CRITICAL,HIGH'
+ timeout: '10m'
+
+ - name: Upload Trivy scan results to GitHub Security tab
+ if: steps.push-decision.outputs.push == 'true'
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
+ # Distinct category per arch so the Security tab surfaces
+ # per-platform findings independently — an amd64-only CVE in
+ # a base layer doesn't get masked by the arm64 scan.
+ category: 'backend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
+
merge-backend:
needs: build-backend
runs-on: ubuntu-latest
+ # No security-events permission here — vulnerability scanning moved
+ # to per-arch build-backend jobs (#476). This job's only job is to
+ # combine the per-arch digests into a multi-arch manifest.
permissions:
contents: read
packages: write
- security-events: write
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -224,23 +281,6 @@ jobs:
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- - name: Run Trivy vulnerability scanner
- if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
- uses: aquasecurity/trivy-action@master
- with:
- image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- format: 'sarif'
- output: 'trivy-backend.sarif'
- severity: 'CRITICAL,HIGH'
- timeout: '10m'
-
- - name: Upload Trivy scan results to GitHub Security tab
- if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
- uses: github/codeql-action/upload-sarif@v4
- with:
- sarif_file: 'trivy-backend.sarif'
- category: 'backend-vulnerabilities'
-
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
@@ -257,6 +297,9 @@ jobs:
permissions:
contents: read
packages: write
+ # See build-backend for the rationale (#476). Same pattern: per-arch
+ # vulnerability scan by digest, SARIF uploaded to the Security tab.
+ security-events: write
steps:
- name: Checkout code
@@ -341,13 +384,40 @@ jobs:
if-no-files-found: error
retention-days: 1
+ # Per-arch vulnerability scan (#476). See build-backend for the
+ # full rationale; identical pattern here, only the image-ref +
+ # SARIF filename + category change.
+ - name: Run Trivy vulnerability scanner (per-arch, by digest)
+ if: steps.push-decision.outputs.push == 'true'
+ uses: aquasecurity/trivy-action@v0.36.0
+ env:
+ # See build-backend for the rationale — pin Trivy's platform
+ # to the matrix arch so its remote-index resolver picks the
+ # right child instead of defaulting to linux/amd64 and
+ # crashing on the arm64 leg.
+ TRIVY_PLATFORM: ${{ matrix.platform }}
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
+ format: 'sarif'
+ output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
+ severity: 'CRITICAL,HIGH'
+ timeout: '10m'
+
+ - name: Upload Trivy scan results to GitHub Security tab
+ if: steps.push-decision.outputs.push == 'true'
+ uses: github/codeql-action/upload-sarif@v4
+ with:
+ sarif_file: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
+ category: 'frontend-vulnerabilities-${{ env.PLATFORM_PAIR }}'
+
merge-frontend:
needs: build-frontend
runs-on: ubuntu-latest
+ # See merge-backend — vulnerability scanning moved to the per-arch
+ # build-frontend matrix (#476). This job only publishes the manifest.
permissions:
contents: read
packages: write
- security-events: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
@@ -418,23 +488,6 @@ jobs:
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- - name: Run Trivy vulnerability scanner
- if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
- uses: aquasecurity/trivy-action@master
- with:
- image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- format: 'sarif'
- output: 'trivy-frontend.sarif'
- severity: 'CRITICAL,HIGH'
- timeout: '10m'
-
- - name: Upload Trivy scan results to GitHub Security tab
- if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
- uses: github/codeql-action/upload-sarif@v4
- with:
- sarif_file: 'trivy-frontend.sarif'
- category: 'frontend-vulnerabilities'
-
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml
new file mode 100644
index 00000000..f93ef6bf
--- /dev/null
+++ b/.github/workflows/install-smoke.yml
@@ -0,0 +1,227 @@
+name: Fresh-install smoke
+
+# Verifies that a clean Postgres install boots cleanly under the same
+# conditions a new user hits on their first `docker compose up -d`. The
+# specific scenarios this guards against — see #484 for the original
+# reproduction:
+#
+# 1. Bind-mounted host directories owned by a UID other than 1001
+# (the container's nodejs user). The entrypoint must self-chown
+# and drop privileges via su-exec.
+# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
+# in #494, the index/created_at error fixed in #511, and any
+# future migration-order issue that only surfaces on an empty DB).
+#
+# Triggers only on changes that touch the install path so unrelated PRs
+# don't pay the build cost.
+
+on:
+ push:
+ branches: [main, beta]
+ paths:
+ - 'backend/Dockerfile'
+ - 'backend/wait-for-db.sh'
+ - 'backend/migrations/**'
+ - 'backend/package*.json'
+ - 'docker-compose.production.yml'
+ - '.github/workflows/install-smoke.yml'
+ pull_request:
+ branches: [main, beta]
+ paths:
+ - 'backend/Dockerfile'
+ - 'backend/wait-for-db.sh'
+ - 'backend/migrations/**'
+ - 'backend/package*.json'
+ - 'docker-compose.production.yml'
+ - '.github/workflows/install-smoke.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ fresh-install:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # Build for the runner's arch only — we just need a runnable image.
+ # The full multi-arch build is the docker-build workflow's job.
+ - name: Build backend image
+ uses: docker/build-push-action@v5
+ with:
+ context: ./backend
+ file: ./backend/Dockerfile
+ load: true
+ tags: picpeak-backend:smoke
+ cache-from: type=gha,scope=install-smoke
+ cache-to: type=gha,mode=max,scope=install-smoke
+
+ - name: Create Docker network
+ run: docker network create picpeak-smoke
+
+ # Mount as UID 1000 (the typical GitHub Actions runner user, and a
+ # common mismatch case on Linux hosts). The entrypoint must chown
+ # this to 1001 itself — that's the regression we're guarding.
+ - name: Prepare host bind-mount dirs owned by UID 1000
+ run: |
+ mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
+ chmod 755 smoke-mounts smoke-mounts/*
+ ls -ld smoke-mounts/*
+
+ - name: Start Postgres
+ run: |
+ docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
+ -e POSTGRES_USER=picpeak \
+ -e POSTGRES_PASSWORD=smokepass \
+ -e POSTGRES_DB=picpeak_prod \
+ --health-cmd="pg_isready -U picpeak -d picpeak_prod" \
+ --health-interval=2s --health-timeout=2s --health-retries=30 \
+ postgres:15-alpine
+
+ - name: Wait for Postgres healthy
+ run: |
+ for i in $(seq 1 60); do
+ status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
+ if [ "$status" = "healthy" ]; then
+ echo "postgres healthy after ${i}s"
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "postgres did not become healthy in 60s"
+ docker logs picpeak-smoke-pg
+ exit 1
+
+ - name: Start backend with mismatched-UID bind mounts (fresh install)
+ run: |
+ docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
+ -e NODE_ENV=production \
+ -e JWT_SECRET=smoketestsecretvalueof32characters \
+ -e DB_HOST=picpeak-smoke-pg \
+ -e DB_USER=picpeak \
+ -e DB_PASSWORD=smokepass \
+ -e DB_NAME=picpeak_prod \
+ -e ADMIN_EMAIL=admin@smoke.local \
+ -e ADMIN_PASSWORD=smokeAdminPass12345 \
+ -e STORAGE_PATH=/app/storage \
+ -v "$PWD/smoke-mounts/storage:/app/storage" \
+ -v "$PWD/smoke-mounts/data:/app/data" \
+ -v "$PWD/smoke-mounts/logs:/app/logs" \
+ picpeak-backend:smoke
+
+ - name: Wait for backend healthy
+ run: |
+ for i in $(seq 1 120); do
+ status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
+ health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
+ if [ "$status" = "exited" ]; then
+ echo "FAIL: backend exited during cold-start (restart loop scenario)"
+ docker logs picpeak-smoke-bk
+ echo "--- error.log ---"
+ cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
+ exit 1
+ fi
+ if [ "$health" = "healthy" ]; then
+ echo "backend healthy after ${i}s"
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "FAIL: backend did not become healthy in 120s"
+ docker ps -a
+ docker logs picpeak-smoke-bk
+ exit 1
+
+ - name: Verify chown happened (container view)
+ run: |
+ # All three dirs should now be owned by nodejs (UID 1001).
+ # If the entrypoint's self-chown branch didn't fire, they'd
+ # still be owned by the runner UID and node would have hit
+ # EACCES creating storage subdirs.
+ for d in /app/storage /app/data /app/logs; do
+ owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
+ if [ "$owner_uid" != "1001" ]; then
+ echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
+ exit 1
+ fi
+ echo "ok: $d owned by UID $owner_uid"
+ done
+
+ - name: Verify app is actually serving
+ run: |
+ # /health is what docker's HEALTHCHECK polls, but hit it
+ # directly to confirm the response shape matches what the
+ # frontend + reverse proxy expect.
+ body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
+ echo "/health => $body"
+ echo "$body" | grep -q '"status":"ok"' || {
+ echo "FAIL: /health did not return status:ok"
+ exit 1
+ }
+
+ - name: Verify node runs as nodejs (not root)
+ run: |
+ # dumb-init runs as root (PID 1), node must be running as
+ # nodejs (UID 1001) — if su-exec drop didn't happen the app
+ # would be running as root which is the security regression
+ # we're guarding against. Alpine ships BusyBox ps, which
+ # doesn't support `-p PID` or pgrep, so list + awk instead.
+ user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
+ if [ "$user" != "nodejs" ]; then
+ echo "FAIL: node running as '$user' (expected nodejs)"
+ docker exec picpeak-smoke-bk ps -o pid,user,comm
+ exit 1
+ fi
+ echo "ok: node running as $user"
+
+ - name: Verify no restart loop
+ run: |
+ restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
+ if [ "$restart_count" -gt 0 ]; then
+ echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
+ docker logs picpeak-smoke-bk
+ exit 1
+ fi
+ echo "ok: 0 restarts"
+
+ # Restart with `--user 5005:5005` (no root, can't chown) against
+ # bind mounts owned by 1000 — entrypoint must fail loud with the
+ # actionable preflight error, not silently restart-loop.
+ - name: Verify preflight fails loud on unwritable mounts
+ run: |
+ docker rm -f picpeak-smoke-bk2 2>/dev/null || true
+ set +e
+ out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
+ -e NODE_ENV=production -e JWT_SECRET=x \
+ -e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
+ -e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
+ -e STORAGE_PATH=/app/storage \
+ -v "$PWD/smoke-mounts/storage:/app/storage" \
+ -v "$PWD/smoke-mounts/data:/app/data" \
+ -v "$PWD/smoke-mounts/logs:/app/logs" \
+ picpeak-backend:smoke 2>&1)
+ rc=$?
+ set -e
+ echo "$out"
+ if [ $rc -eq 0 ]; then
+ echo "FAIL: preflight should have exited non-zero"
+ exit 1
+ fi
+ echo "$out" | grep -q "is not writable by UID 5005" || {
+ echo "FAIL: preflight error message missing or wrong"
+ exit 1
+ }
+ echo "ok: preflight failed loud with actionable error"
+
+ - name: Cleanup
+ if: always()
+ run: |
+ docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
+ docker network rm picpeak-smoke 2>/dev/null || true
diff --git a/.github/workflows/schema-drift.yml b/.github/workflows/schema-drift.yml
new file mode 100644
index 00000000..8811d77b
--- /dev/null
+++ b/.github/workflows/schema-drift.yml
@@ -0,0 +1,189 @@
+name: Schema drift (#530)
+
+# Verifies that `migrate:safe` can recover a DB that's been seeded only
+# by `initializeDatabase()` — the recovery scenario where the migrations
+# tracking table is empty but the schema already has the modern bootstrap.
+#
+# This is NOT how production reaches its state on normal installs or
+# upgrades. The scenario only fires when:
+# - A backup was restored that captured tables but not the migrations
+# table (manifest divergence),
+# - Someone manually invoked initializeDatabase() outside the migration
+# runner (recovery / debugging),
+# - The DB was moved between systems and the migrations table was not
+# copied along.
+#
+# When `detectExistingSchema()` sees the modern-bootstrap fingerprint
+# (photo_categories + cms_pages tables) but an empty migrations table,
+# it treats it as an "existing deployment" — which runs the legacy
+# chain first. Legacy/008 renames email_templates.subject → subject_en,
+# but core/029 (which runs later in this chain) inserts email templates
+# referencing the pre-rename column name. The chain dies with a
+# "column subject does not exist" error.
+#
+# Fix (in the same PR as this workflow): when the modern-bootstrap
+# fingerprint is detected, mark all legacy migrations as applied so the
+# chain matches what a fresh install runs — only core/*, in order.
+#
+# This workflow boots the failing scenario from scratch on every PR
+# that touches the migrations or db.js, so any future migration with
+# the same shape is caught before merge.
+
+on:
+ push:
+ branches: [main, beta]
+ paths:
+ - 'backend/migrations/**'
+ - 'backend/src/database/db.js'
+ - 'backend/knexfile.js'
+ - '.github/workflows/schema-drift.yml'
+ pull_request:
+ branches: [main, beta]
+ paths:
+ - 'backend/migrations/**'
+ - 'backend/src/database/db.js'
+ - 'backend/knexfile.js'
+ - '.github/workflows/schema-drift.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ upgrade-from-bootstrap:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ services:
+ postgres:
+ image: postgres:15-alpine
+ env:
+ POSTGRES_USER: picpeak
+ POSTGRES_PASSWORD: testpass
+ POSTGRES_DB: picpeak_drift
+ options: >-
+ --health-cmd "pg_isready -U picpeak -d picpeak_drift"
+ --health-interval 2s
+ --health-timeout 2s
+ --health-retries 30
+ ports:
+ - 5432:5432
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+ cache-dependency-path: backend/package-lock.json
+
+ - name: Install backend deps
+ working-directory: ./backend
+ run: npm ci
+
+ # Step 1: simulate the recovery state — DB has the modern bootstrap
+ # (post-initializeDatabase) but no migrations recorded. Calling
+ # initializeDatabase() directly outside the migration runner is the
+ # one-line repro for backup-restore-lost-migrations and manual-
+ # invocation paths.
+ - name: Seed DB with initializeDatabase() only
+ working-directory: ./backend
+ env:
+ NODE_ENV: production
+ DATABASE_CLIENT: pg
+ DB_HOST: localhost
+ DB_PORT: 5432
+ DB_USER: picpeak
+ DB_PASSWORD: testpass
+ DB_NAME: picpeak_drift
+ run: |
+ node -e "require('./src/database/db').initializeDatabase().then(() => { console.log('bootstrap ok'); process.exit(0); }).catch(e => { console.error('bootstrap FAILED:', e.message); process.exit(1); })"
+
+ # Sanity-check the recovery shape before migrate:safe runs. If
+ # initializeDatabase() ever stops producing photo_categories +
+ # cms_pages, the fingerprint check would silently no-op and this
+ # workflow would lose its teeth — assert the precondition.
+ - name: Assert recovery-state fingerprint
+ env:
+ PGPASSWORD: testpass
+ run: |
+ installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
+ if [ "$installed" != "2" ]; then
+ echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
+ psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
+ exit 1
+ fi
+ # initializeDatabase() doesn't create the `migrations` tracking
+ # table — that's the migrate:safe runner's job. So in the recovery
+ # scenario, the table either (a) doesn't exist yet or (b) exists
+ # but is empty (e.g. someone created it but didn't populate it).
+ # Both are valid recovery states; check via to_regclass first so
+ # we don't parse a SELECT against a nonexistent table.
+ has_migrations_table=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT to_regclass('public.migrations')::text")
+ if [ -z "$has_migrations_table" ]; then
+ migrations_count=0
+ else
+ migrations_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations")
+ fi
+ if [ "$migrations_count" != "0" ]; then
+ echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
+ exit 1
+ fi
+ echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
+
+ # Step 2: run migrate:safe — the test. Before #530's fix in
+ # detectExistingSchema, this died at core/029 with a "column
+ # subject does not exist" error. After the fix, it should complete
+ # cleanly with every migration either applied or marked.
+ - name: Run migrate:safe against the recovery state
+ working-directory: ./backend
+ env:
+ NODE_ENV: production
+ DATABASE_CLIENT: pg
+ DB_HOST: localhost
+ DB_PORT: 5432
+ DB_USER: picpeak
+ DB_PASSWORD: testpass
+ DB_NAME: picpeak_drift
+ run: npm run migrate:safe
+
+ # Step 3: schema-shape assertion. A fresh install through migrate:
+ # safe produces 48 tables; the recovery scenario should converge
+ # to the same number. Off-by-one is fine but a 10+ table delta
+ # means a migration silently bailed in the recovery path.
+ - name: Assert final schema matches fresh-install shape
+ env:
+ PGPASSWORD: testpass
+ run: |
+ tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
+ echo "Final table count: $tables"
+ # Allow a small drift window — exact count creeps over time as
+ # new migrations land; tight pin would force a workflow edit
+ # on every schema PR. 40+ is a healthy floor that catches the
+ # original bug (which left 17 tables) while staying robust to
+ # forward changes.
+ if [ "$tables" -lt 40 ]; then
+ echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
+ psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
+ exit 1
+ fi
+ echo "ok: schema converged to a fresh-install-equivalent shape."
+
+ # Step 4: verify the legacy migrations were all marked applied
+ # (rather than silently bailing inside the chain). The fix in
+ # detectExistingSchema marks legacy/* when the modern bootstrap
+ # is detected — confirm the markings actually landed.
+ - name: Assert legacy migrations marked applied
+ env:
+ PGPASSWORD: testpass
+ run: |
+ legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
+ if [ "$legacy_count" -lt 7 ]; then
+ echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
+ psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
+ exit 1
+ fi
+ echo "ok: legacy migrations marked applied by detectExistingSchema."
diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json
index f254ab1a..9d8ba6b2 100644
--- a/.release-please-manifest-beta.json
+++ b/.release-please-manifest-beta.json
@@ -1,3 +1,3 @@
{
- ".": "3.42.1-beta.0"
+ ".": "3.55.0-beta.0"
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3b0bf20d..66aedcec 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,7 +38,7 @@ Unsure where to begin? You can start by looking through these issues:
### Pull Requests
-1. **Fork the repo** and create your branch from `main`
+1. **Fork the repo** and create your branch from `beta`
2. **Install dependencies**:
```bash
cd backend && npm install
diff --git a/backend/.env.example b/backend/.env.example
index ba134257..15ab3ddd 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -10,18 +10,31 @@ PORT=3001
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Auth cookie Secure flag
-# unset - default: follows NODE_ENV (production=true, dev=false)
-# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
+# unset - default: 'auto' in production, false in dev (#427)
+# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
+# login appears to succeed but the browser silently drops the
+# cookie, leaving you in a redirect loop. Only set this if you
+# ALWAYS reach the site via HTTPS)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
-# auto - decide per request: Secure on HTTPS, not on HTTP
+# auto - decide per request: Secure on HTTPS, not on HTTP. Reads
+# req.secure from Express which respects X-Forwarded-Proto from a
+# trusted reverse proxy. This is the default and is the right
+# choice for most deployments.
#
-# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
-# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
-# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
-# req.secure from Express, which respects the X-Forwarded-Proto header
-# when the proxy is in the trust list.
+# Why 'auto' is the default in production:
+# - On real HTTPS (reverse proxy with X-Forwarded-Proto), req.secure is
+# true → Secure flag is still emitted. No security regression vs. true.
+# - On plain HTTP (LAN access, first-time install before reverse proxy is
+# wired up), req.secure is false → Secure flag is omitted → login works
+# instead of silently looping back to /admin/login.
#
-# Requirements for auto mode:
+# When you'd set this explicitly:
+# - COOKIE_SECURE=true → strict HTTPS-only deployments where you want
+# defense in depth against accidentally serving over HTTP.
+# - COOKIE_SECURE=false → you intentionally only ever serve over HTTP and
+# don't want the per-request check (rare).
+#
+# Requirements for 'auto' mode to detect HTTPS correctly:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 9a35453a..e8e66a74 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -35,12 +35,15 @@ RUN apk upgrade --no-cache
RUN npm install -g npm@10
# Install dumb-init for proper signal handling, postgresql-client for database
-# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
-# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
+# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
+# privilege drop in wait-for-db.sh (see #484: container starts as root so it
+# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
+# before running the app). Alpine's ffmpeg package ships both `ffmpeg` and
+# `ffprobe` built natively against musl libc — the npm
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
-RUN apk add --no-cache dumb-init postgresql-client ffmpeg
+RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -56,9 +59,18 @@ RUN chmod -R a+r /app && chmod +x wait-for-db.sh
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
-USER nodejs
+# No USER directive — the container starts as root so wait-for-db.sh can
+# chown bind-mounted host directories to UID 1001 before dropping privs
+# via su-exec. See #484 for the fresh-install restart loop this avoids.
EXPOSE 3000
+# Healthcheck hits the same /health endpoint already used by the e2e
+# runner and by the docker-compose `depends_on: condition: service_healthy`
+# checks. wget is part of the Alpine base image. Long start-period covers
+# the wait-for-db.sh delay before the Node process starts listening.
+HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
+
ENTRYPOINT ["dumb-init", "--"]
CMD ["./wait-for-db.sh", "node", "server.js"]
diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev
index 2f056807..71d62c7f 100644
--- a/backend/Dockerfile.dev
+++ b/backend/Dockerfile.dev
@@ -30,5 +30,8 @@ USER nodejs
EXPOSE 3000
+HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
+
ENTRYPOINT ["dumb-init", "--"]
CMD ["npm", "run", "dev"]
\ No newline at end of file
diff --git a/backend/__tests__/integration/imageProcessor.storage.test.js b/backend/__tests__/integration/imageProcessor.storage.test.js
index f9d5140e..5340cb95 100644
--- a/backend/__tests__/integration/imageProcessor.storage.test.js
+++ b/backend/__tests__/integration/imageProcessor.storage.test.js
@@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
expect(await storage.exists(key)).toBe(true);
});
+ test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
+ const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
+ const key = await imageProcessor.generatePreviewImage(src);
+ expect(key).toBe('previews/preview_preview-source.jpg');
+ expect(await storage.exists(key)).toBe(true);
+
+ if (storage.kind() === 'local') {
+ const meta = await sharp(storage.resolveLocalPath(key)).metadata();
+ expect(meta.format).toBe('jpeg');
+ // Source is 800x600 and default longEdge is 1920 with
+ // withoutEnlargement: true → preview must NOT be upscaled.
+ expect(meta.width).toBe(800);
+ expect(meta.height).toBe(600);
+ }
+ });
+
+ test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
+ const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
+ const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
+ expect(await storage.exists(key)).toBe(true);
+ if (storage.kind() === 'local') {
+ const meta = await sharp(storage.resolveLocalPath(key)).metadata();
+ // 800x600 → fit:'inside' inside 400×400 → 400×300.
+ expect(meta.width).toBe(400);
+ expect(meta.height).toBe(300);
+ }
+ });
+
+ test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
+ const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
+ const key = await imageProcessor.generatePreviewImage(src);
+ expect(await imageProcessor.isPreviewValid(key)).toBe(true);
+ expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
+ });
+
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
const key = await imageProcessor.generateThumbnail(src);
diff --git a/backend/__tests__/routes/adminUsers.dateNormalize.test.js b/backend/__tests__/routes/adminUsers.dateNormalize.test.js
new file mode 100644
index 00000000..d30d1320
--- /dev/null
+++ b/backend/__tests__/routes/adminUsers.dateNormalize.test.js
@@ -0,0 +1,123 @@
+/**
+ * Pin the date-field normalisation in adminUsers transformer (#485).
+ *
+ * The Users page crashed on native/SQLite installs because Postgres
+ * returned ISO strings while SQLite returned epoch-millisecond
+ * integers, and the frontend `parseISO()` blew up on numbers with
+ * "e.split is not a function". The transformer now coerces every
+ * shape to an ISO 8601 string before serialising.
+ *
+ * These tests guard the contract so a future refactor can't quietly
+ * regress and re-break the same page on the same DB.
+ */
+
+const adminUsersRoute = require('../../src/routes/adminUsers');
+const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
+
+describe('toIso', () => {
+ it('passes null and undefined through unchanged', () => {
+ expect(toIso(null)).toBeNull();
+ expect(toIso(undefined)).toBeUndefined();
+ // Empty string also short-circuits — important so an unset
+ // last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
+ expect(toIso('')).toBe('');
+ });
+
+ it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
+ // 2026-05-14T10:00:00.000Z, in epoch ms.
+ const epochMs = 1778752800000;
+ expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
+ });
+
+ it('coerces a stringified large integer to an ISO 8601 string', () => {
+ // Some SQLite drivers stringify large integers because they
+ // overflow JS safe-integer in the driver's serialiser. Re-coerce
+ // so the frontend doesn't try to parseISO('1778752800000').
+ expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
+ });
+
+ it('coerces a Date instance via toISOString', () => {
+ const d = new Date('2026-01-01T12:34:56.000Z');
+ expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
+ });
+
+ it('passes an existing ISO string through unchanged', () => {
+ const iso = '2026-05-14T10:00:00.000Z';
+ expect(toIso(iso)).toBe(iso);
+ });
+
+ it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
+ // Defensive: anything that isn't a 10+ digit integer string is
+ // treated as already-stringified — the date library will surface
+ // the failure cleanly if it's malformed, rather than the
+ // transformer silently rewriting it.
+ expect(toIso('2026-05-14')).toBe('2026-05-14');
+ });
+});
+
+describe('transformUser', () => {
+ it('normalises last_login, created_at, updated_at coming from SQLite', () => {
+ const sqliteRow = {
+ id: 1,
+ username: 'admin',
+ email: 'admin@example.com',
+ is_active: 1,
+ last_login: 1778752800000, // epoch ms
+ last_login_ip: '127.0.0.1',
+ created_at: 1778751144600, // epoch ms
+ updated_at: 1778751242320, // epoch ms
+ role_id: 1,
+ role_name: 'super_admin',
+ role_display_name: 'Super Admin',
+ created_by_username: null,
+ };
+
+ const out = transformUser(sqliteRow);
+
+ expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
+ expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
+ expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
+ // Other fields untouched.
+ expect(out.username).toBe('admin');
+ expect(out.lastLoginIp).toBe('127.0.0.1');
+ });
+
+ it('leaves Postgres ISO strings intact', () => {
+ const pgRow = {
+ id: 2,
+ username: 'second',
+ email: 'second@example.com',
+ is_active: true,
+ last_login: '2026-05-14T10:00:00.000Z',
+ created_at: '2026-05-13T08:00:00.000Z',
+ updated_at: '2026-05-14T09:00:00.000Z',
+ };
+ const out = transformUser(pgRow);
+ expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
+ expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
+ expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
+ });
+
+ it('keeps last_login null when the user has never logged in', () => {
+ const out = transformUser({
+ id: 3, username: 'fresh', email: 'fresh@example.com',
+ is_active: 1, last_login: null,
+ });
+ expect(out.lastLogin).toBeNull();
+ });
+});
+
+describe('transformInvitation', () => {
+ it('normalises expires_at and created_at from SQLite epoch-ms', () => {
+ const out = transformInvitation({
+ id: 9,
+ email: 'invitee@example.com',
+ expires_at: 1779357600000,
+ created_at: 1778752800000,
+ role_name: 'admin',
+ invited_by: 'admin',
+ });
+ expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
+ expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
+ });
+});
diff --git a/backend/__tests__/services/downloadFilenameService.test.js b/backend/__tests__/services/downloadFilenameService.test.js
new file mode 100644
index 00000000..bfbda6f4
--- /dev/null
+++ b/backend/__tests__/services/downloadFilenameService.test.js
@@ -0,0 +1,76 @@
+/**
+ * Pure-logic tests for the #493 download-filename helpers that don't depend
+ * on the DB (those are covered by the route integration suite).
+ */
+
+const {
+ pickRawDownloadName,
+ getZipEntryNames,
+} = require('../../src/services/downloadFilenameService');
+
+describe('pickRawDownloadName', () => {
+ it('returns the storage filename when the toggle is off', () => {
+ expect(
+ pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, false)
+ ).toBe('slug_001.jpg');
+ });
+
+ it('returns original_filename when the toggle is on', () => {
+ expect(
+ pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, true)
+ ).toBe('DSC_1.jpg');
+ });
+
+ it('falls back to storage filename when original_filename is missing', () => {
+ expect(
+ pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: null }, true)
+ ).toBe('slug_001.jpg');
+ });
+
+ it('produces a stable last-resort name when both are missing', () => {
+ expect(pickRawDownloadName({ id: 42 }, true)).toBe('photo-42.jpg');
+ });
+});
+
+describe('getZipEntryNames', () => {
+ it('uses original filenames with deterministic suffixes on collision', () => {
+ const photos = [
+ { id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1234.jpg' },
+ { id: 2, filename: 'slug_002.jpg', original_filename: 'DSC_1234.jpg' },
+ { id: 3, filename: 'slug_003.jpg', original_filename: 'DSC_1235.jpg' },
+ ];
+ expect(getZipEntryNames(photos, true)).toEqual([
+ 'DSC_1234.jpg',
+ 'DSC_1234_1.jpg',
+ 'DSC_1235.jpg',
+ ]);
+ });
+
+ it('falls back to storage filename per-photo when original is missing', () => {
+ const photos = [
+ { id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' },
+ { id: 2, filename: 'slug_002.jpg', original_filename: null },
+ ];
+ expect(getZipEntryNames(photos, true)).toEqual([
+ 'DSC_1.jpg',
+ 'slug_002.jpg',
+ ]);
+ });
+
+ it('returns storage filenames when the toggle is off, dedup still applies', () => {
+ const photos = [
+ { id: 1, filename: 'a.jpg', original_filename: 'DSC_1.jpg' },
+ { id: 2, filename: 'a.jpg', original_filename: 'DSC_2.jpg' },
+ ];
+ expect(getZipEntryNames(photos, false)).toEqual(['a.jpg', 'a_1.jpg']);
+ });
+
+ it('sanitizes path-traversal attempts that sneak into original_filename', () => {
+ const photos = [
+ { id: 1, filename: 'slug_001.jpg', original_filename: '../etc/passwd' },
+ ];
+ const [name] = getZipEntryNames(photos, true);
+ expect(name).not.toContain('..');
+ expect(name).not.toContain('/');
+ });
+});
diff --git a/backend/__tests__/utils/filenameSanitizer.test.js b/backend/__tests__/utils/filenameSanitizer.test.js
new file mode 100644
index 00000000..e3093e01
Binary files /dev/null and b/backend/__tests__/utils/filenameSanitizer.test.js differ
diff --git a/backend/init-production.sh b/backend/init-production.sh
deleted file mode 100755
index d3fb599f..00000000
--- a/backend/init-production.sh
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/bin/sh
-# init-production.sh - Production initialization script
-
-set -e
-
-echo "🚀 Initializing PicPeak Production Environment..."
-
-# Wait for services to be ready
-echo "⏳ Waiting for database to be fully ready..."
-sleep 3
-
-# Fix permissions if running as root (shouldn't happen with proper Dockerfile)
-if [ "$(id -u)" = "0" ]; then
- echo "🔧 Fixing file permissions..."
- chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null || true
-fi
-
-# Create required directories
-echo "📁 Creating required directories..."
-mkdir -p /app/storage/events/active \
- /app/storage/events/archived \
- /app/storage/thumbnails \
- /app/storage/uploads/logos \
- /app/storage/uploads/favicons \
- /app/data \
- /app/logs
-
-# Run migrations with safe runner
-echo "🗄️ Running database migrations (safe mode)..."
-NODE_ENV=production npm run migrate:safe
-
-# Create admin user if environment variables are set
-if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
- echo "👤 Creating admin user..."
- node scripts/create-admin.js \
- --email "$ADMIN_EMAIL" \
- --username "${ADMIN_USERNAME:-admin}" \
- --password "$ADMIN_PASSWORD" || echo "Admin user might already exist"
-fi
-
-# Initialize email configuration if variables are set
-if [ -n "$SMTP_HOST" ]; then
- echo "📧 Email configuration detected via environment variables"
-fi
-
-echo "✅ Production initialization complete!"
-echo "🌐 Starting application server..."
-
-# Start the application
-exec node server.js
\ No newline at end of file
diff --git a/backend/migrations/core/035_enhance_backup_system.js b/backend/migrations/core/035_enhance_backup_system.js
index 37df3c7d..dc4ca074 100644
--- a/backend/migrations/core/035_enhance_backup_system.js
+++ b/backend/migrations/core/035_enhance_backup_system.js
@@ -83,11 +83,15 @@ async function up() {
});
}
- // Add indexes if they don't exist
+ // Add indexes if they don't exist. backup_runs (created in 029) tracks
+ // chronology via `started_at` — the original `created_at` reference
+ // here was a bug that emitted a "column does not exist" ERROR in the
+ // postgres log on every fresh install (silently caught below). See
+ // migration 105 for the matching back-fix on already-applied installs.
try {
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)');
- await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_created_mode ON backup_runs(created_at, backup_mode)');
+ await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_started_mode ON backup_runs(started_at, backup_mode)');
} catch (error) {
console.log('Note: Some indexes may already exist, continuing...');
}
@@ -144,16 +148,17 @@ async function up() {
});
}
- // Add composite indexes for common query patterns
+ // Add composite indexes for common query patterns. Same `started_at`
+ // correction as above — `created_at` doesn't exist on backup_runs.
try {
await db.raw(`
- CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
- ON backup_runs(created_at DESC)
+ CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
+ ON backup_runs(started_at DESC)
WHERE status = 'completed' AND backup_mode = 'full';
`);
await db.raw(`
- CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
- ON backup_runs(parent_backup_id, created_at)
+ CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
+ ON backup_runs(parent_backup_id, started_at)
WHERE backup_mode = 'incremental';
`);
} catch (error) {
@@ -194,10 +199,13 @@ async function down() {
});
}
- // Drop indexes
+ // Drop indexes. `idx_backup_runs_created_mode` is the legacy name
+ // shipped by an earlier revision of this migration; kept in the drop
+ // list so a down() against any historic state cleans up either name.
try {
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent');
+ await db.raw('DROP INDEX IF EXISTS idx_backup_runs_started_mode');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode');
} catch (error) {
// Ignore errors if indexes don't exist
diff --git a/backend/migrations/core/072_add_max_upload_batch_size.js b/backend/migrations/core/072_add_max_upload_batch_size.js
new file mode 100644
index 00000000..abd381d9
--- /dev/null
+++ b/backend/migrations/core/072_add_max_upload_batch_size.js
@@ -0,0 +1,38 @@
+/**
+ * Migration: re-add the configurable upload batch size setting (#509).
+ *
+ * Originally shipped via PR #214 (#208 fix) — users behind Cloudflare
+ * Tunnel and other reverse proxies with per-request size caps need to
+ * bound the chunked-upload size so they don't lose every batch >100MB.
+ * That migration + frontend wiring was lost during a `Merge main into
+ * beta for release/beta-to-main` resolution that picked main's older
+ * tree over beta's, silently deleting the file and reinstating the
+ * hardcoded 500MB chunk in PhotoUpload.tsx.
+ *
+ * Re-introducing the exact same migration here. Idempotent: skips the
+ * insert if the row already exists (e.g. installs that did go through
+ * the original 072 between #214 merge and the main-into-beta merge,
+ * where the migrations-table row was preserved even after the file
+ * was deleted).
+ */
+
+exports.up = async function(knex) {
+ const exists = await knex('app_settings')
+ .where({ setting_key: 'general_max_upload_batch_size_mb' })
+ .first();
+
+ if (!exists) {
+ await knex('app_settings').insert({
+ setting_key: 'general_max_upload_batch_size_mb',
+ setting_value: JSON.stringify(95),
+ setting_type: 'general',
+ updated_at: new Date()
+ });
+ }
+};
+
+exports.down = async function(knex) {
+ await knex('app_settings')
+ .where({ setting_key: 'general_max_upload_batch_size_mb' })
+ .del();
+};
diff --git a/backend/migrations/core/087_add_update_notification_test_template.js b/backend/migrations/core/087_add_update_notification_test_template.js
new file mode 100644
index 00000000..da337b45
--- /dev/null
+++ b/backend/migrations/core/087_add_update_notification_test_template.js
@@ -0,0 +1,130 @@
+/**
+ * Migration 087: Add a dedicated email template for the admin "Send Test
+ * Email" button on the Update Notifications settings page (#418).
+ *
+ * Previously the button reused the version_update_available template via
+ * sendUpdateNotificationNow(), which bailed out early when no real update
+ * was pending — so admins on the latest version had no way to verify
+ * their SMTP / recipient list config worked.
+ *
+ * The test template makes the intent unambiguous in the inbox ("This is
+ * a test of your update-notification setup, no action needed") and lets
+ * the send code run unconditionally regardless of update availability.
+ *
+ * Languages: EN + DE only, matching the convention of the existing
+ * version_update_available template (070). The email_templates table
+ * doesn't have nl/pt/ru columns; sendTemplateEmail falls back to EN.
+ */
+
+exports.up = async function(knex) {
+ console.log('Running migration: 087_add_update_notification_test_template');
+
+ const existing = await knex('email_templates')
+ .where('template_key', 'version_update_test')
+ .first();
+
+ if (existing) {
+ console.log(' version_update_test template already exists, skipping insert');
+ return;
+ }
+
+ await knex('email_templates').insert({
+ template_key: 'version_update_test',
+ subject_en: '[TEST] PicPeak Update Notification — configuration check',
+ subject_de: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
+ body_html_en: `
+
This is a test email
+
+
You are receiving this message because an administrator clicked
+Send Test Email on the Update Notifications page of your
+PicPeak installation.
+
+
+
Installed version: {{current_version}}
+
Channel: {{channel}}
+
Recipient address: {{recipient_email}}
+
+
+
If you can read this email, your SMTP configuration and the recipient
+list are working correctly. When a real new version becomes available,
+PicPeak will send a separate notification with release notes and update
+instructions.
+
+
No action is required.
+You may safely delete this message.
+
+
Best regards,
+Your PicPeak Installation
`,
+ body_text_en: `This is a test email
+
+You are receiving this message because an administrator clicked
+"Send Test Email" on the Update Notifications page of your PicPeak
+installation.
+
+Installed version: {{current_version}}
+Channel: {{channel}}
+Recipient address: {{recipient_email}}
+
+If you can read this email, your SMTP configuration and the recipient
+list are working correctly. When a real new version becomes available,
+PicPeak will send a separate notification with release notes and update
+instructions.
+
+No action is required. You may safely delete this message.
+
+Best regards,
+Your PicPeak Installation`,
+ body_html_de: `
+
Dies ist eine Test-E-Mail
+
+
Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
+"Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
+Test-E-Mail senden geklickt hat.
+
+
+
Installierte Version: {{current_version}}
+
Kanal: {{channel}}
+
Empfänger-Adresse: {{recipient_email}}
+
+
+
Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
+und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
+ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
+und Update-Anweisungen.
+
+
Es ist keine Aktion
+erforderlich. Sie können diese Nachricht gefahrlos löschen.
+
+
Mit freundlichen Grüßen,
+Ihre PicPeak-Installation
`,
+ body_text_de: `Dies ist eine Test-E-Mail
+
+Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
+"Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
+"Test-E-Mail senden" geklickt hat.
+
+Installierte Version: {{current_version}}
+Kanal: {{channel}}
+Empfänger-Adresse: {{recipient_email}}
+
+Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
+und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
+ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
+und Update-Anweisungen.
+
+Es ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.
+
+Mit freundlichen Grüßen,
+Ihre PicPeak-Installation`,
+ variables: JSON.stringify(['current_version', 'channel', 'recipient_email'])
+ });
+
+ console.log('Migration 087_add_update_notification_test_template completed');
+};
+
+exports.down = async function(knex) {
+ console.log('Rollback: 087_add_update_notification_test_template');
+ await knex('email_templates')
+ .where('template_key', 'version_update_test')
+ .del();
+};
diff --git a/backend/migrations/core/088_add_feature_flags.js b/backend/migrations/core/088_add_feature_flags.js
new file mode 100644
index 00000000..9f70c672
--- /dev/null
+++ b/backend/migrations/core/088_add_feature_flags.js
@@ -0,0 +1,88 @@
+/**
+ * Migration 088: Feature flags table.
+ *
+ * Backs the Features tab on the admin Settings page. Flags gate which
+ * product surfaces appear in the main sidebar and (in future PRs) which
+ * background jobs run.
+ *
+ * Existing-vs-fresh detection:
+ * The Features tab introduces a curated set of "default ON" flags
+ * (galleries, reminderEmails, analytics, userManagement) and "default
+ * OFF" flags for surfaces that aren't built yet (calendar, quotes,
+ * bills, messaging). For a brand-new install those defaults are right
+ * out of the box. For an existing install, we want every flag ON so
+ * nothing in the admin's UI silently disappears the moment they
+ * upgrade — they can opt out later via the Features tab.
+ *
+ * Detection rule: if the `events` table has any rows at migration
+ * time, treat this as an existing install. Empty events = fresh.
+ * This is single-shot (the migration only runs once) and atomic
+ * (no race window). It picks up the rare edge case where an admin
+ * upgrades immediately after running setup but before creating an
+ * event — they'll get fresh-install defaults, which is acceptable
+ * (they can flip flags on the Features page).
+ *
+ * Schema:
+ * - key (PK): the flag identifier (matches FeatureKey on the frontend)
+ * - value: the boolean state
+ * - updated_at: last-changed timestamp
+ * - updated_by: admin id of the last person who flipped it (nullable
+ * for the migration-seeded rows)
+ */
+
+exports.up = async function(knex) {
+ console.log('Running migration: 088_add_feature_flags');
+
+ const exists = await knex.schema.hasTable('feature_flags');
+ if (!exists) {
+ await knex.schema.createTable('feature_flags', (table) => {
+ table.string('key', 64).primary();
+ table.boolean('value').notNullable();
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+ table.integer('updated_by').references('id').inTable('admin_users').onDelete('SET NULL');
+ });
+ console.log(' created feature_flags table');
+ } else {
+ console.log(' feature_flags table already exists, skipping create');
+ }
+
+ // Detect install age. Use events table — it's user-created content,
+ // unlike admin_users which is seeded by migration 001.
+ const eventCountRow = await knex('events').count({ count: '*' }).first();
+ const eventCount = parseInt(eventCountRow?.count || 0, 10);
+ const isExistingInstall = eventCount > 0;
+ console.log(` detected ${isExistingInstall ? 'EXISTING' : 'FRESH'} install (events count: ${eventCount})`);
+
+ // Spec defaults (frontend/src/contexts/FeatureFlagsContext.tsx).
+ // For an existing install every flag becomes TRUE so nothing
+ // disappears from the admin UI on upgrade.
+ const FLAGS_FRESH = {
+ galleries: true, // always-on, locked
+ reminderEmails: true, // existing cron, locked-on for now
+ calendar: false, // surface not built yet
+ calendarBooking: false, // ditto
+ quotes: false, // surface not built yet
+ bills: false, // surface not built yet (depends on quotes)
+ messaging: false, // surface not built yet
+ analytics: true, // existing surface
+ userManagement: true, // existing surface
+ };
+ const flagsToSeed = isExistingInstall
+ ? Object.fromEntries(Object.keys(FLAGS_FRESH).map((k) => [k, true]))
+ : FLAGS_FRESH;
+
+ for (const [key, value] of Object.entries(flagsToSeed)) {
+ const existingRow = await knex('feature_flags').where({ key }).first();
+ if (!existingRow) {
+ await knex('feature_flags').insert({ key, value });
+ }
+ }
+ console.log(` seeded ${Object.keys(flagsToSeed).length} flags`);
+
+ console.log('Migration 088_add_feature_flags completed');
+};
+
+exports.down = async function(knex) {
+ console.log('Rollback: 088_add_feature_flags');
+ await knex.schema.dropTableIfExists('feature_flags');
+};
diff --git a/backend/migrations/core/089_footer_overhaul.js b/backend/migrations/core/089_footer_overhaul.js
new file mode 100644
index 00000000..d3e6b17c
--- /dev/null
+++ b/backend/migrations/core/089_footer_overhaul.js
@@ -0,0 +1,119 @@
+/**
+ * Migration 089: Footer overhaul (#441 + #440).
+ *
+ * Three concerns, all in the gallery footer area:
+ *
+ * 1. Per-CMS-page "Show in footer" toggle (#441 part a). Lets admins
+ * hide legal-link entries (Impressum, Datenschutz, etc.) when the
+ * target jurisdiction doesn't require them. Default TRUE so existing
+ * installs see no change.
+ *
+ * 2. Social links in the footer (#441 part b). Five branding settings
+ * for the canonical photographer-relevant networks: Facebook,
+ * Instagram, WhatsApp, X/Twitter, YouTube. All optional strings.
+ *
+ * 3. Promotional markdown slot above/below the footer (#440). Global
+ * default + per-event override with a three-way mode switch:
+ * - inherit (default): use the global, render nothing if global empty
+ * - custom: render the per-event markdown
+ * - off: suppress entirely for this event regardless of global
+ *
+ * Markdown only (no raw HTML) — the rendering pipeline is
+ * `marked → DOMPurify` on the frontend so admins can format text
+ * without opening an XSS surface.
+ */
+
+exports.up = async function(knex) {
+ console.log('Running migration: 089_footer_overhaul');
+
+ // 1. cms_pages.show_in_footer
+ const hasShowInFooter = await knex.schema.hasColumn('cms_pages', 'show_in_footer');
+ if (!hasShowInFooter) {
+ await knex.schema.alterTable('cms_pages', (table) => {
+ table.boolean('show_in_footer').notNullable().defaultTo(true);
+ });
+ console.log(' added cms_pages.show_in_footer (default true)');
+ } else {
+ console.log(' cms_pages.show_in_footer already exists, skipping');
+ }
+
+ // 2. events.promo_mode + events.promo_markdown
+ const hasPromoMode = await knex.schema.hasColumn('events', 'promo_mode');
+ if (!hasPromoMode) {
+ await knex.schema.alterTable('events', (table) => {
+ table.string('promo_mode', 16).notNullable().defaultTo('inherit');
+ });
+ console.log(' added events.promo_mode (default "inherit")');
+ } else {
+ console.log(' events.promo_mode already exists, skipping');
+ }
+
+ const hasPromoMarkdown = await knex.schema.hasColumn('events', 'promo_markdown');
+ if (!hasPromoMarkdown) {
+ await knex.schema.alterTable('events', (table) => {
+ table.text('promo_markdown').nullable();
+ });
+ console.log(' added events.promo_markdown (nullable text)');
+ } else {
+ console.log(' events.promo_markdown already exists, skipping');
+ }
+
+ // 3. New branding settings rows. Use the same `branding_`
+ // convention as the existing 21 branding rows (verified via
+ // SELECT setting_key FROM app_settings WHERE setting_key LIKE 'branding_%').
+ const newSettings = [
+ { setting_key: 'branding_facebook_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
+ { setting_key: 'branding_instagram_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
+ { setting_key: 'branding_whatsapp_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
+ { setting_key: 'branding_twitter_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
+ { setting_key: 'branding_youtube_url', setting_value: JSON.stringify(''), setting_type: 'branding' },
+ { setting_key: 'branding_promo_markdown', setting_value: JSON.stringify(''), setting_type: 'branding' },
+ // 'above_footer' | 'below_footer' (string instead of enum so we can
+ // expand without a schema change later).
+ { setting_key: 'branding_promo_position', setting_value: JSON.stringify('above_footer'), setting_type: 'branding' },
+ ];
+
+ for (const setting of newSettings) {
+ const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
+ if (!exists) {
+ await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
+ }
+ }
+ console.log(` ensured ${newSettings.length} branding rows`);
+
+ console.log('Migration 089_footer_overhaul completed');
+};
+
+exports.down = async function(knex) {
+ console.log('Rollback: 089_footer_overhaul');
+
+ if (await knex.schema.hasColumn('cms_pages', 'show_in_footer')) {
+ await knex.schema.alterTable('cms_pages', (table) => {
+ table.dropColumn('show_in_footer');
+ });
+ }
+
+ if (await knex.schema.hasColumn('events', 'promo_mode')) {
+ await knex.schema.alterTable('events', (table) => {
+ table.dropColumn('promo_mode');
+ });
+ }
+
+ if (await knex.schema.hasColumn('events', 'promo_markdown')) {
+ await knex.schema.alterTable('events', (table) => {
+ table.dropColumn('promo_markdown');
+ });
+ }
+
+ await knex('app_settings')
+ .whereIn('setting_key', [
+ 'branding_facebook_url',
+ 'branding_instagram_url',
+ 'branding_whatsapp_url',
+ 'branding_twitter_url',
+ 'branding_youtube_url',
+ 'branding_promo_markdown',
+ 'branding_promo_position',
+ ])
+ .del();
+};
diff --git a/backend/migrations/core/090_add_customer_accounts.js b/backend/migrations/core/090_add_customer_accounts.js
new file mode 100644
index 00000000..7fa5e3e8
--- /dev/null
+++ b/backend/migrations/core/090_add_customer_accounts.js
@@ -0,0 +1,313 @@
+/**
+ * Migration: Add Customer Accounts (recurring user logins)
+ *
+ * Implements the customer tier from discussion the-luap/picpeak#354.
+ *
+ * Three new tables:
+ * - customer_accounts : the user record (email + bcrypt password)
+ * - customer_invitations : admin → customer invite handshake (mirrors admin_invitations)
+ * - event_customer_assignments: many-to-many junction with events
+ *
+ * Three new RBAC permissions seeded so super_admin and admin roles can
+ * manage customers immediately after migrate. Editor / viewer remain
+ * locked out by design (matches the existing users.* permissions pattern).
+ *
+ * Migration is idempotent — every step checks for existing state so a
+ * partial install can be resumed.
+ */
+
+exports.up = async function(knex) {
+ // ---- customer_accounts -----------------------------------------------
+ if (!(await knex.schema.hasTable('customer_accounts'))) {
+ await knex.schema.createTable('customer_accounts', (table) => {
+ table.increments('id').primary();
+ table.string('email', 255).unique().notNullable();
+
+ // --- auth ---------------------------------------------------------
+ // password_hash is nullable until the invitation is accepted —
+ // an unaccepted account row exists only after acceptInvitation, so
+ // in practice this is always set, but the column is nullable to
+ // allow for future "admin creates pre-loaded account" flows.
+ table.string('password_hash', 255);
+ table.boolean('must_change_password').notNullable().defaultTo(false);
+ table.boolean('is_active').notNullable().defaultTo(true);
+ // Tracks password-change time so JWTs issued before a password
+ // change are rejected by customerAuth middleware. Mirrors the
+ // admin_users.password_changed_at column.
+ table.timestamp('password_changed_at');
+ table.timestamp('last_login');
+ table.string('last_login_ip', 45);
+ table.string('preferred_language', 8).defaultTo('en');
+
+ // --- contact ------------------------------------------------------
+ // Salutation honorific (Herr / Frau / Mx / Dr / Other). Stored as
+ // free text rather than an enum so future locales (German "Frau",
+ // French "Mme", legal titles "Dr.", etc.) don't need a migration.
+ table.string('salutation', 32);
+ table.string('first_name', 80);
+ table.string('last_name', 80);
+ // Convenience display name kept separately so the dashboard can
+ // greet customers without joining first/last (e.g. "Welcome, Luca").
+ table.string('display_name', 120);
+ table.string('phone', 40);
+ table.string('company_name', 120);
+
+ // --- billing / address (for future quotes & invoicing) -----------
+ table.string('billing_email', 255);
+ table.string('vat_id', 40);
+ table.string('address_line1', 255);
+ table.string('address_line2', 255);
+ table.string('postal_code', 20);
+ table.string('city', 120);
+ table.string('state', 120);
+ table.string('country_code', 2); // ISO 3166-1 alpha-2
+
+ // --- audit --------------------------------------------------------
+ table.text('notes'); // free-text admin notes, never shown to the customer
+ table.integer('created_by_admin_id').unsigned()
+ .references('id').inTable('admin_users').onDelete('SET NULL');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+
+ table.index(['email']);
+ table.index(['is_active']);
+ table.index(['last_name']);
+ table.index(['company_name']);
+ });
+ }
+
+ // ---- customer_invitations --------------------------------------------
+ if (!(await knex.schema.hasTable('customer_invitations'))) {
+ await knex.schema.createTable('customer_invitations', (table) => {
+ table.increments('id').primary();
+ table.string('email', 255).notNullable();
+ // 64 chars = 32 bytes hex = 256 bits — same entropy as admin invites.
+ table.string('token', 64).unique().notNullable();
+ table.integer('invited_by').unsigned()
+ .references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
+ table.timestamp('expires_at').notNullable();
+ table.timestamp('accepted_at');
+ table.integer('accepted_customer_id').unsigned()
+ .references('id').inTable('customer_accounts').onDelete('SET NULL');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+
+ table.index(['token']);
+ table.index(['email']);
+ table.index(['expires_at']);
+ table.index(['accepted_at']);
+ });
+ }
+
+ // ---- event_customer_assignments --------------------------------------
+ if (!(await knex.schema.hasTable('event_customer_assignments'))) {
+ await knex.schema.createTable('event_customer_assignments', (table) => {
+ table.increments('id').primary();
+ table.integer('event_id').unsigned().notNullable()
+ .references('id').inTable('events').onDelete('CASCADE');
+ table.integer('customer_account_id').unsigned().notNullable()
+ .references('id').inTable('customer_accounts').onDelete('CASCADE');
+ table.integer('assigned_by_admin_id').unsigned()
+ .references('id').inTable('admin_users').onDelete('SET NULL');
+ table.timestamp('assigned_at').defaultTo(knex.fn.now());
+
+ table.unique(['event_id', 'customer_account_id']);
+ table.index(['customer_account_id']);
+ table.index(['event_id']);
+ });
+ }
+
+ // ---- RBAC permissions -------------------------------------------------
+ // Insert the three customers.* permissions if they're not already present
+ // (guards against re-running the migration in dev). The same idempotency
+ // pattern as 055_add_permissions_table.js.
+ const existingPermissions = await knex('permissions').select('name');
+ const existingNames = new Set(existingPermissions.map((p) => p.name));
+ const newPermissions = [
+ {
+ name: 'customers.view',
+ display_name: 'View Customers',
+ category: 'customers',
+ description: 'View customer accounts and their event assignments',
+ },
+ {
+ name: 'customers.create',
+ display_name: 'Invite Customers',
+ category: 'customers',
+ description: 'Issue customer invitations and assign customers to events',
+ },
+ {
+ name: 'customers.delete',
+ display_name: 'Deactivate Customers',
+ category: 'customers',
+ description: 'Deactivate customer accounts and revoke their access',
+ },
+ ].filter((p) => !existingNames.has(p.name));
+
+ if (newPermissions.length > 0) {
+ await knex('permissions').insert(newPermissions);
+ }
+
+ // Grant the three permissions to super_admin and admin so the feature
+ // is usable immediately. We look up role / permission ids fresh because
+ // the inserts above just landed.
+ const roles = await knex('roles').select('id', 'name')
+ .whereIn('name', ['super_admin', 'admin']);
+ const perms = await knex('permissions').select('id', 'name')
+ .whereIn('name', ['customers.view', 'customers.create', 'customers.delete']);
+
+ if (roles.length > 0 && perms.length > 0) {
+ const existing = await knex('role_permissions').select('role_id', 'permission_id');
+ const existingSet = new Set(existing.map((m) => `${m.role_id}-${m.permission_id}`));
+ const inserts = [];
+ for (const role of roles) {
+ for (const perm of perms) {
+ const key = `${role.id}-${perm.id}`;
+ if (!existingSet.has(key)) {
+ inserts.push({ role_id: role.id, permission_id: perm.id });
+ }
+ }
+ }
+ if (inserts.length > 0) {
+ await knex('role_permissions').insert(inserts);
+ }
+ }
+
+ // ---- email template seed ---------------------------------------------
+ // Schema is the post-075 shape: a master `email_templates` row keyed by
+ // template_key, plus one `email_template_translations` row per language.
+ // The legacy subject/body_html/body_text columns on email_templates may
+ // still exist for back-compat, so we populate both wherever the column
+ // is present — defensive, since some installs may run mid-upgrade.
+ if (await knex.schema.hasTable('email_templates')) {
+ const existing = await knex('email_templates')
+ .where('template_key', 'customer_invitation')
+ .first();
+
+ let templateId = existing?.id;
+ if (!existing) {
+ // Build the master row by introspecting the columns that actually
+ // exist on this install. The schema has drifted across migrations
+ // (075 adds language-specific columns then 075 normalises into a
+ // separate translations table; some installs lack `created_at` /
+ // `updated_at` on the master row). Anything not present is skipped
+ // silently rather than causing the whole migration to abort and
+ // taking the backend down with it.
+ const masterColumns = await knex('email_templates').columnInfo();
+ const insertRow = {
+ template_key: 'customer_invitation',
+ };
+ if (masterColumns.variables) {
+ insertRow.variables = JSON.stringify(['invite_link', 'expires_at']);
+ }
+ if (masterColumns.created_at) insertRow.created_at = knex.fn.now();
+ if (masterColumns.updated_at) insertRow.updated_at = knex.fn.now();
+ // Populate legacy single-language columns when present so older
+ // email service code paths still find a sensible default body.
+ if (masterColumns.subject) insertRow.subject = 'You\'ve been invited to access your photo galleries';
+ if (masterColumns.body_html) {
+ insertRow.body_html = '
You\'ve been invited to create a customer account. Set up your account (expires {{expires_at}}).
';
+ }
+ if (masterColumns.body_text) {
+ insertRow.body_text = 'Set up your customer account: {{invite_link}} (expires {{expires_at}}).';
+ }
+ // Some installs have language-specific master columns from migration 075.
+ if (masterColumns.subject_en) insertRow.subject_en = insertRow.subject || 'You\'ve been invited to access your photo galleries';
+ if (masterColumns.body_html_en) insertRow.body_html_en = insertRow.body_html || '';
+ if (masterColumns.body_text_en) insertRow.body_text_en = insertRow.body_text || '';
+
+ const [insertedId] = await knex('email_templates').insert(insertRow).returning('id');
+ templateId = insertedId?.id || insertedId;
+ }
+
+ if (templateId && await knex.schema.hasTable('email_template_translations')) {
+ const transColumns = await knex('email_template_translations').columnInfo();
+ const buildTranslationRow = (language, subject, bodyHtml, bodyText) => {
+ const row = { template_id: templateId, language };
+ if (transColumns.subject) row.subject = subject;
+ if (transColumns.body_html) row.body_html = bodyHtml;
+ if (transColumns.body_text) row.body_text = bodyText;
+ if (transColumns.created_at) row.created_at = new Date();
+ if (transColumns.updated_at) row.updated_at = new Date();
+ return row;
+ };
+
+ // The button uses the wrapper's `.button` class instead of inline
+ // styles, which inherits the admin-configured `email_primary_color`
+ // (Settings → Branding → Email palette). Inline `background-color`
+ // would override it and lock the button to the legacy green
+ // regardless of branding — that's the bug shipped on the very
+ // first cut of this template.
+ const en = buildTranslationRow(
+ 'en',
+ 'You\'ve been invited to access your photo galleries',
+ `
+
Welcome to your photo galleries
+
You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:
+
{{invite_link}}
+
If you weren't expecting this email, you can safely ignore it.
`,
+ `Welcome to your photo galleries
+
+You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
+
+Set up your account: {{invite_link}}
+
+This invitation expires on {{expires_at}}.
+
+If you weren't expecting this email, you can safely ignore it.`
+ );
+
+ const de = buildTranslationRow(
+ 'de',
+ 'Sie wurden eingeladen, auf Ihre Fotogalerien zuzugreifen',
+ `
+
Willkommen bei Ihren Fotogalerien
+
Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:
+
{{invite_link}}
+
Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.
`,
+ `Willkommen bei Ihren Fotogalerien
+
+Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
+
+Konto einrichten: {{invite_link}}
+
+Diese Einladung läuft am {{expires_at}} ab.
+
+Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.`
+ );
+
+ for (const row of [en, de]) {
+ const exists = await knex('email_template_translations')
+ .where({ template_id: templateId, language: row.language })
+ .first();
+ if (!exists) {
+ await knex('email_template_translations').insert(row);
+ }
+ }
+ }
+ }
+};
+
+exports.down = async function(knex) {
+ // Drop in reverse dependency order. Permissions / role grants are left
+ // alone — they're idempotent on re-run and cleaning them on rollback
+ // would require deleting role_permissions rows we may not own.
+ await knex.schema.dropTableIfExists('event_customer_assignments');
+ await knex.schema.dropTableIfExists('customer_invitations');
+ await knex.schema.dropTableIfExists('customer_accounts');
+
+ // Best-effort cleanup of the three customers.* permissions if no other
+ // code path inserted them. role_permissions rows cascade via FK.
+ await knex('permissions').whereIn('name', [
+ 'customers.view',
+ 'customers.create',
+ 'customers.delete',
+ ]).del();
+};
diff --git a/backend/migrations/core/091_add_customer_invitation_prefill.js b/backend/migrations/core/091_add_customer_invitation_prefill.js
new file mode 100644
index 00000000..d5b62d61
--- /dev/null
+++ b/backend/migrations/core/091_add_customer_invitation_prefill.js
@@ -0,0 +1,49 @@
+/**
+ * Migration: Allow admins to pre-fill customer profile fields on invite (#354 follow-up).
+ *
+ * Adds a single `prefill_data` JSON column to `customer_invitations`. When an
+ * admin invites a customer they can optionally pass first/last name, company,
+ * phone and a billing address — that data is stashed here and copied onto the
+ * new customer_accounts row by acceptInvitation(). The customer can then
+ * confirm or edit those values on the accept-invite form before submitting.
+ *
+ * JSON instead of one column per field because:
+ * - the prefill set may grow (vat id, salutation, etc.) and we don't want a
+ * migration for every UI tweak;
+ * - the data is only ever read+copied wholesale at accept time, never
+ * filtered/queried.
+ *
+ * Migration is idempotent — bails out if the column already exists.
+ */
+
+exports.up = async function(knex) {
+ const hasTable = await knex.schema.hasTable('customer_invitations');
+ if (!hasTable) {
+ // Migration 087 not yet run — nothing to alter. Should never happen in
+ // practice (knex runs migrations in order), but be defensive.
+ return;
+ }
+
+ const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
+ if (hasColumn) {
+ return;
+ }
+
+ await knex.schema.alterTable('customer_invitations', (table) => {
+ // Knex's `json` type maps to JSONB on Postgres and TEXT on SQLite, which
+ // matches how we already store other free-form payloads in this codebase
+ // (see app_settings.theme_config). Nullable: invitations sent the
+ // old way (or via the API without a body) should still work.
+ table.json('prefill_data');
+ });
+};
+
+exports.down = async function(knex) {
+ const hasTable = await knex.schema.hasTable('customer_invitations');
+ if (!hasTable) return;
+ const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
+ if (!hasColumn) return;
+ await knex.schema.alterTable('customer_invitations', (table) => {
+ table.dropColumn('prefill_data');
+ });
+};
diff --git a/backend/migrations/core/092_customer_features_branding_resets.js b/backend/migrations/core/092_customer_features_branding_resets.js
new file mode 100644
index 00000000..2f4133f6
--- /dev/null
+++ b/backend/migrations/core/092_customer_features_branding_resets.js
@@ -0,0 +1,190 @@
+/**
+ * Migration: Customer-surface feature flags, branding toggles, and password resets (#354 follow-up).
+ *
+ * Three things in one migration so a single rollback returns the install
+ * to the prior state:
+ *
+ * 1. Per-customer feature flags on customer_accounts:
+ * feature_calendar / feature_quotes / feature_bills (BOOLEAN, default
+ * false). Default false because the matching pages are still
+ * coming-soon stubs — admin opts a customer in once the feature is
+ * actually useful for them. Combined with the global toggles below
+ * via AND-logic in the customer session response.
+ *
+ * 2. Global customer-surface toggles seeded into app_settings under
+ * setting_type='customer_surface':
+ * customer_feature_calendar_enabled (default false)
+ * customer_feature_quotes_enabled (default false)
+ * customer_feature_bills_enabled (default false)
+ * customer_show_logo (default true — preserves
+ * current visual behaviour)
+ * customer_show_company_name (default true — preserves
+ * current visual behaviour)
+ *
+ * 3. customer_password_resets table — admin-triggered password reset
+ * flow. Distinct from customer_invitations (which is the "create
+ * account" flow): a reset always points at an existing customer_id
+ * and updates the existing password_hash on accept.
+ *
+ * Plus the customer_password_reset email template, idempotently seeded.
+ *
+ * All steps are idempotent — a partial rollout can be resumed by re-running
+ * `knex migrate:latest`.
+ */
+
+exports.up = async function(knex) {
+ // ---- per-customer feature flags ---------------------------------------
+
+ if (await knex.schema.hasTable('customer_accounts')) {
+ const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
+ for (const col of cols) {
+ const exists = await knex.schema.hasColumn('customer_accounts', col);
+ if (!exists) {
+ // Add as a separate alterTable per column so a half-applied
+ // migration (column A added, B failing) leaves the table in a
+ // consistent state on retry.
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.boolean(col).notNullable().defaultTo(false);
+ });
+ }
+ }
+ }
+
+ // ---- global customer-surface settings ---------------------------------
+
+ if (await knex.schema.hasTable('app_settings')) {
+ const seeds = [
+ // Features: default false. Admin must explicitly enable on the
+ // settings page before the corresponding sidebar entry can show
+ // for any customer.
+ { setting_key: 'customer_feature_calendar_enabled', setting_value: false, setting_type: 'customer_surface' },
+ { setting_key: 'customer_feature_quotes_enabled', setting_value: false, setting_type: 'customer_surface' },
+ { setting_key: 'customer_feature_bills_enabled', setting_value: false, setting_type: 'customer_surface' },
+ // Branding: default true so existing installs keep their current
+ // logo + company name in the customer header until the admin
+ // opts to hide them.
+ { setting_key: 'customer_show_logo', setting_value: true, setting_type: 'customer_surface' },
+ { setting_key: 'customer_show_company_name', setting_value: true, setting_type: 'customer_surface' },
+ ];
+
+ for (const row of seeds) {
+ const existing = await knex('app_settings').where('setting_key', row.setting_key).first();
+ if (!existing) {
+ // Postgres JSONB column accepts both a JSON literal and a
+ // JSON-stringified value depending on driver version. Stringify
+ // for SQLite compatibility; Postgres accepts the same shape.
+ await knex('app_settings').insert({
+ setting_key: row.setting_key,
+ setting_value: JSON.stringify(row.setting_value),
+ setting_type: row.setting_type,
+ });
+ }
+ }
+ }
+
+ // ---- customer_password_resets table -----------------------------------
+
+ if (!(await knex.schema.hasTable('customer_password_resets'))) {
+ await knex.schema.createTable('customer_password_resets', (table) => {
+ table.increments('id').primary();
+ // 64-char hex token, same shape as invitations and admin invites.
+ table.string('token', 64).unique().notNullable();
+ // Always points at an existing customer_account; if the account is
+ // deleted, the reset disappears too.
+ table.integer('customer_account_id').notNullable()
+ .references('id').inTable('customer_accounts').onDelete('CASCADE');
+ table.integer('requested_by_admin_id')
+ .references('id').inTable('admin_users').onDelete('SET NULL');
+ table.timestamp('expires_at').notNullable();
+ table.timestamp('used_at');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.index('token');
+ table.index('customer_account_id');
+ });
+ }
+
+ // ---- customer_password_reset email template ---------------------------
+
+ if (await knex.schema.hasTable('email_templates')) {
+ const existing = await knex('email_templates').where('template_key', 'customer_password_reset').first();
+ if (!existing) {
+ // Two email_templates schema variants exist in the wild:
+ //
+ // (a) legacy single-locale: subject / body_html / body_text columns
+ // (b) multi-locale: subject_en / subject_de / body_html_en /
+ // body_text_en / ... — all NOT NULL on at least one install
+ // (the maintainer's prod), where the previous version of this
+ // migration silently produced a 23502 NOT NULL violation and
+ // crash-looped the backend.
+ //
+ // Detect whichever variant is present and populate every matching
+ // column. For non-en locale columns we fall back to the English
+ // content so the install isn't left with NULL-violation rows;
+ // proper translations can be filled in later via the admin UI.
+ const cols = await knex('email_templates').columnInfo();
+ const SUBJECT = 'Reset your password';
+ const BODY_HTML = `
Hello,
+
Your photographer has triggered a password reset for your customer account.
If you didn't expect this, you can ignore the message — your current password will keep working until you click the link.
`;
+ const BODY_TEXT = `Your photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`;
+
+ const row = {};
+ if ('template_key' in cols) row.template_key = 'customer_password_reset';
+ if ('language' in cols) row.language = 'en';
+ if ('is_active' in cols) row.is_active = true;
+ if ('created_at' in cols) row.created_at = new Date();
+ if ('updated_at' in cols) row.updated_at = new Date();
+
+ // Populate every subject/body column that exists, regardless of
+ // locale suffix. Fallback content == English; safe because email
+ // templates are user-editable post-install.
+ for (const colName of Object.keys(cols)) {
+ if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
+ row[colName] = SUBJECT;
+ } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
+ row[colName] = BODY_HTML;
+ } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
+ row[colName] = BODY_TEXT;
+ }
+ }
+
+ await knex('email_templates').insert(row);
+ }
+ }
+};
+
+exports.down = async function(knex) {
+ // ---- table -----------------------------------------------------------
+ if (await knex.schema.hasTable('customer_password_resets')) {
+ await knex.schema.dropTable('customer_password_resets');
+ }
+
+ // ---- per-customer flags ---------------------------------------------
+ if (await knex.schema.hasTable('customer_accounts')) {
+ const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
+ for (const col of cols) {
+ if (await knex.schema.hasColumn('customer_accounts', col)) {
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.dropColumn(col);
+ });
+ }
+ }
+ }
+
+ // ---- settings -------------------------------------------------------
+ if (await knex.schema.hasTable('app_settings')) {
+ await knex('app_settings').whereIn('setting_key', [
+ 'customer_feature_calendar_enabled',
+ 'customer_feature_quotes_enabled',
+ 'customer_feature_bills_enabled',
+ 'customer_show_logo',
+ 'customer_show_company_name',
+ ]).del();
+ }
+
+ // ---- template --------------------------------------------------------
+ if (await knex.schema.hasTable('email_templates')) {
+ await knex('email_templates').where('template_key', 'customer_password_reset').del();
+ }
+};
diff --git a/backend/migrations/core/093_customer_feature_default_visible.js b/backend/migrations/core/093_customer_feature_default_visible.js
new file mode 100644
index 00000000..72169a55
--- /dev/null
+++ b/backend/migrations/core/093_customer_feature_default_visible.js
@@ -0,0 +1,63 @@
+/**
+ * Migration: Flip the per-customer feature flag semantic to "opt-out".
+ *
+ * Original semantic (089): both global toggle AND per-customer flag had
+ * to be ON for a customer to see Calendar/Quotes/Bills. Defaults: false.
+ * Result: enabling a feature globally was a no-op until the admin clicked
+ * through every customer detail page and toggled them on individually.
+ *
+ * New semantic (this migration): global toggle is the master; per-customer
+ * flag defaults to TRUE and only acts as an override-to-hide. So:
+ *
+ * - Global ON, per-customer default (true) → visible
+ * - Global ON, per-customer set to false → hidden for this customer
+ * - Global OFF, per-customer anything → hidden (master wins)
+ *
+ * Migration steps:
+ * 1. Update column defaults to true so new customer_accounts rows
+ * auto-opt-in.
+ * 2. Flip every existing row's feature_* columns from false → true.
+ * Rows that were never touched (i.e. ALL of them at this stage in
+ * dev) end up with the new default. If a maintainer had already
+ * hand-toggled a customer to false to hide a feature, that's
+ * indistinguishable from the seeded default at this layer — so
+ * this migration deliberately overwrites. Acceptable because the
+ * original semantic only shipped for one image and nobody is
+ * relying on hand-set false values yet.
+ *
+ * Idempotent: re-running is a no-op (the UPDATE just confirms current
+ * values).
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('customer_accounts'))) return;
+
+ // Step 1 — change defaults. Knex's .alter() rewrites the column;
+ // we keep notNullable to match 089.
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.boolean('feature_calendar').notNullable().defaultTo(true).alter();
+ table.boolean('feature_quotes').notNullable().defaultTo(true).alter();
+ table.boolean('feature_bills').notNullable().defaultTo(true).alter();
+ });
+
+ // Step 2 — flip existing rows so they pick up the new default. Without
+ // this, customers created on the 089-shipped image stay invisible even
+ // after the admin enables the feature globally.
+ await knex('customer_accounts').update({
+ feature_calendar: true,
+ feature_quotes: true,
+ feature_bills: true,
+ });
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('customer_accounts'))) return;
+ // Restore the 089 default of false. Don't bulk-update existing rows
+ // back to false: that would silently hide features for customers the
+ // admin had explicitly enabled post-090.
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.boolean('feature_calendar').notNullable().defaultTo(false).alter();
+ table.boolean('feature_quotes').notNullable().defaultTo(false).alter();
+ table.boolean('feature_bills').notNullable().defaultTo(false).alter();
+ });
+};
diff --git a/backend/migrations/core/094_customer_invitation_email_themed_button.js b/backend/migrations/core/094_customer_invitation_email_themed_button.js
new file mode 100644
index 00000000..8894e16b
--- /dev/null
+++ b/backend/migrations/core/094_customer_invitation_email_themed_button.js
@@ -0,0 +1,90 @@
+/**
+ * Migration: Re-theme the customer_invitation email's CTA button.
+ *
+ * The original 087 seed inlined `background-color: #5C8762` on the
+ * "Set up your account" anchor, which locked the button to the legacy
+ * green regardless of the admin's `email_primary_color` setting
+ * (Settings → Branding → Email palette). The wrapper template
+ * (emailProcessor.wrapEmailHtml) already exposes a `.button` class
+ * that inherits the configured palette — switching the anchor over
+ * is a one-line change, but existing installs already have the bad
+ * HTML in their email_template_translations rows. This migration
+ * rewrites those rows so the next outbound invitation picks up the
+ * brand colour.
+ *
+ * Idempotent: only updates rows whose stored body still contains the
+ * old hardcoded anchor markup. If the admin has hand-edited the
+ * template (typical for non-English locales they translated
+ * themselves) the row is left alone.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+ if (!(await knex.schema.hasTable('email_template_translations'))) return;
+
+ const master = await knex('email_templates')
+ .where('template_key', 'customer_invitation')
+ .first();
+ if (!master) return; // 087 hasn't run on this install — nothing to fix.
+
+ // English translation
+ const enRow = await knex('email_template_translations')
+ .where({ template_id: master.id, language: 'en' })
+ .first();
+ if (enRow && typeof enRow.body_html === 'string'
+ && enRow.body_html.includes('background-color: #5C8762')
+ && enRow.body_html.includes('Set up your account')) {
+ const newHtml = `
+
Welcome to your photo galleries
+
You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
Als u deze e-mail niet verwachtte, kunt u deze gerust negeren.
`,
+ body_text: `Welkom bij PicPeak\n\nU bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.\n\nStel uw account in: {{invitation_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`,
+ },
+ pt: {
+ subject: 'Você foi convidado para o PicPeak como {{role_name}}',
+ body_html: `
Bem-vindo(a) ao PicPeak
+
Você foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.
Se você não esperava este e-mail, pode ignorá-lo com segurança.
`,
+ body_text: `Bem-vindo(a) ao PicPeak\n\nVocê foi convidado(a) por {{inviter_name}} para participar do PicPeak como {{role_name}}.\n\nConfigure sua conta: {{invitation_link}}\n\nEste convite expira em {{expires_at}}.`,
+ },
+ ru: {
+ subject: 'Вас пригласили присоединиться к PicPeak в роли {{role_name}}',
+ body_html: `
Добро пожаловать в PicPeak
+
{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.
+
Перейдите по ссылке ниже, чтобы настроить учётную запись:
Срок действия приглашения истекает {{expires_at}}.
+
Если вы не ожидали этого письма, можете его проигнорировать.
`,
+ body_text: `Добро пожаловать в PicPeak\n\n{{inviter_name}} пригласил(а) вас присоединиться к PicPeak в роли {{role_name}}.\n\nНастроить учётную запись: {{invitation_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`,
+ },
+ fr: {
+ subject: 'Vous avez été invité(e) à rejoindre PicPeak en tant que {{role_name}}',
+ body_html: `
Bienvenue sur PicPeak
+
{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.
+
Cliquez sur le lien ci-dessous pour configurer votre compte :
Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.
`,
+ body_text: `Bienvenue sur PicPeak\n\n{{inviter_name}} vous a invité(e) à rejoindre PicPeak en tant que {{role_name}}.\n\nConfigurer le compte : {{invitation_link}}\n\nCette invitation expire le {{expires_at}}.`,
+ },
+ },
+
+ admin_password_reset: {
+ nl: {
+ subject: 'Uw PicPeak-administratorwachtwoord is opnieuw ingesteld',
+ body_html: `
Wachtwoord opnieuw ingesteld
+
Hallo {{admin_name}},
+
Uw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.
+
Klik op de onderstaande link om een nieuw wachtwoord in te stellen:
Deze link verloopt op {{expires_at}}. Heeft u deze actie niet aangevraagd? Neem dan onmiddellijk contact op met uw teambeheerder.
`,
+ body_text: `Wachtwoord opnieuw ingesteld\n\nHallo {{admin_name}},\n\nUw PicPeak-administratorwachtwoord is opnieuw ingesteld door {{reset_by}}.\n\nStel een nieuw wachtwoord in: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.`,
+ },
+ pt: {
+ subject: 'Sua senha de administrador do PicPeak foi redefinida',
+ body_html: `
Senha redefinida
+
Olá {{admin_name}},
+
Sua senha de administrador do PicPeak foi redefinida por {{reset_by}}.
+
Clique no link abaixo para definir uma nova senha:
Este link expira em {{expires_at}}. Se você não solicitou esta ação, entre em contato com o administrador da sua equipe imediatamente.
`,
+ body_text: `Senha redefinida\n\nOlá {{admin_name}},\n\nSua senha de administrador do PicPeak foi redefinida por {{reset_by}}.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.`,
+ },
+ ru: {
+ subject: 'Ваш пароль администратора PicPeak был сброшен',
+ body_html: `
Пароль сброшен
+
Здравствуйте, {{admin_name}}!
+
Ваш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.
+
Перейдите по ссылке ниже, чтобы задать новый пароль:
Срок действия ссылки истекает {{expires_at}}. Если вы не запрашивали это действие, немедленно свяжитесь с администратором вашей команды.
`,
+ body_text: `Пароль сброшен\n\nЗдравствуйте, {{admin_name}}!\n\nВаш пароль администратора PicPeak был сброшен пользователем {{reset_by}}.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.`,
+ },
+ fr: {
+ subject: 'Votre mot de passe administrateur PicPeak a été réinitialisé',
+ body_html: `
Mot de passe réinitialisé
+
Bonjour {{admin_name}},
+
Votre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.
+
Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :
Ce lien expire le {{expires_at}}. Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement votre administrateur.
`,
+ body_text: `Mot de passe réinitialisé\n\nBonjour {{admin_name}},\n\nVotre mot de passe administrateur PicPeak a été réinitialisé par {{reset_by}}.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.`,
+ },
+ },
+
+ // ────────────────────────────────────────────────────────────────
+ // Customer portal (#354). customer_invitation already has hand-tuned
+ // en/de from migration 090 (themed button via migration 094).
+ // ────────────────────────────────────────────────────────────────
+ customer_invitation: {
+ nl: {
+ subject: 'U bent uitgenodigd om uw fotogalerijen te bekijken',
+ body_html: `
Welkom bij uw fotogalerijen
+
U bent uitgenodigd om een klantaccount aan te maken, zodat u al uw evenementgalerijen op één plek kunt bekijken — geen aparte links en wachtwoorden meer.
Deze uitnodiging verloopt op {{expires_at}}. Werkt de link niet? Kopieer hem en plak hem in uw browser:
+
{{invite_link}}
+
Heeft u deze e-mail niet verwacht? U kunt deze gerust negeren.
`,
+ body_text: `Welkom bij uw fotogalerijen\n\nU bent uitgenodigd om een klantaccount aan te maken zodat u al uw galerijen op één plek kunt bekijken.\n\nAccount instellen: {{invite_link}}\n\nDeze uitnodiging verloopt op {{expires_at}}.`,
+ },
+ pt: {
+ subject: 'Você foi convidado(a) a acessar suas galerias de fotos',
+ body_html: `
Bem-vindo(a) às suas galerias
+
Você foi convidado(a) a criar uma conta de cliente para visualizar todas as suas galerias de eventos em um só lugar — sem mais links e senhas separados.
Este convite expira em {{expires_at}}. Se o link não funcionar, copie e cole-o no navegador:
+
{{invite_link}}
+
Se você não esperava este e-mail, pode ignorá-lo com segurança.
`,
+ body_text: `Bem-vindo(a) às suas galerias\n\nVocê foi convidado(a) a criar uma conta de cliente para acessar todas as suas galerias em um só lugar.\n\nConfigurar conta: {{invite_link}}\n\nEste convite expira em {{expires_at}}.`,
+ },
+ ru: {
+ subject: 'Вас пригласили получить доступ к вашим фотогалереям',
+ body_html: `
Добро пожаловать в ваши галереи
+
Вас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи событий в одном месте — больше никаких отдельных ссылок и паролей.
Срок действия приглашения истекает {{expires_at}}. Если ссылка не работает, скопируйте её в адресную строку браузера:
+
{{invite_link}}
+
Если вы не ожидали этого письма, можете его проигнорировать.
`,
+ body_text: `Добро пожаловать в ваши галереи\n\nВас пригласили создать учётную запись клиента, чтобы видеть все ваши галереи в одном месте.\n\nНастроить учётную запись: {{invite_link}}\n\nСрок действия приглашения истекает {{expires_at}}.`,
+ },
+ fr: {
+ subject: 'Vous avez été invité(e) à accéder à vos galeries photo',
+ body_html: `
Bienvenue dans vos galeries photo
+
Vous avez été invité(e) à créer un compte client pour voir toutes vos galeries d'événements en un seul endroit — plus de liens et mots de passe séparés.
Cette invitation expire le {{expires_at}}. Si le lien ne fonctionne pas, copiez-le et collez-le dans votre navigateur :
+
{{invite_link}}
+
Si vous n'attendiez pas cet e-mail, vous pouvez l'ignorer en toute sécurité.
`,
+ body_text: `Bienvenue dans vos galeries photo\n\nVous avez été invité(e) à créer un compte client pour accéder à toutes vos galeries en un seul endroit.\n\nConfigurer le compte : {{invite_link}}\n\nCette invitation expire le {{expires_at}}.`,
+ },
+ },
+
+ // Customer password reset (#354 follow-up). Seeded by migration 092
+ // in the legacy columns with English-only copy (the EN string was
+ // broadcast to every subject_*/body_html_* column to satisfy NOT
+ // NULL on multi-locale schemas). Replacing here with a proper
+ // per-locale set including hand-tuned EN/DE plus AI-generated
+ // nl/pt/ru/fr.
+ customer_password_reset: {
+ en: {
+ subject: 'Reset your customer account password',
+ body_html: `
Hello,
+
Your photographer has triggered a password reset for your customer account.
If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.
`,
+ body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`,
+ },
+ de: {
+ subject: 'Passwort für dein Kundenkonto zurücksetzen',
+ body_html: `
Hallo,
+
Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.
Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.
`,
+ body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`,
+ },
+ nl: {
+ subject: 'Wachtwoord van uw klantaccount opnieuw instellen',
+ body_html: `
Hallo,
+
Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.
Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.
`,
+ body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`,
+ },
+ pt: {
+ subject: 'Redefina a senha da sua conta de cliente',
+ body_html: `
Olá,
+
Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.
Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.
`,
+ body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`,
+ },
+ ru: {
+ subject: 'Сброс пароля вашей клиентской учётной записи',
+ body_html: `
Здравствуйте!
+
Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.
Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.
`,
+ body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`,
+ },
+ fr: {
+ subject: 'Réinitialisez le mot de passe de votre compte client',
+ body_html: `
Bonjour,
+
Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.
Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.
`,
+ body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`,
+ },
+ },
+
+ // ────────────────────────────────────────────────────────────────
+ // Database backups
+ // ────────────────────────────────────────────────────────────────
+ database_backup_completed: {
+ nl: {
+ subject: '[PicPeak] Database-back-up succesvol',
+ body_html: `
Database-back-up voltooid
+
De geplande database-back-up is succesvol voltooid.
+
+
Tijdstip: {{completed_at}}
+
Grootte: {{backup_size}}
+
Locatie: {{backup_path}}
+
`,
+ body_text: `Database-back-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`,
+ },
+ pt: {
+ subject: '[PicPeak] Backup do banco de dados concluído',
+ body_html: `
Backup do banco de dados concluído
+
O backup agendado do banco de dados foi concluído com sucesso.
+
+
Horário: {{completed_at}}
+
Tamanho: {{backup_size}}
+
Localização: {{backup_path}}
+
`,
+ body_text: `Backup do banco de dados concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`,
+ },
+ ru: {
+ subject: '[PicPeak] Резервная копия БД успешно создана',
+ body_html: `
Резервная копия базы данных создана
+
Запланированное резервное копирование базы данных успешно завершено.
+
+
Время: {{completed_at}}
+
Размер: {{backup_size}}
+
Расположение: {{backup_path}}
+
`,
+ body_text: `Резервная копия базы данных создана\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`,
+ },
+ fr: {
+ subject: '[PicPeak] Sauvegarde de la base de données réussie',
+ body_html: `
Sauvegarde de la base de données terminée
+
La sauvegarde planifiée de la base de données s'est terminée avec succès.
+
+
Heure : {{completed_at}}
+
Taille : {{backup_size}}
+
Emplacement : {{backup_path}}
+
`,
+ body_text: `Sauvegarde de la base de données terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`,
+ },
+ },
+
+ database_backup_failed: {
+ nl: {
+ subject: '[PicPeak] Database-back-up MISLUKT',
+ body_html: `
Database-back-up mislukt
+
De geplande database-back-up is mislukt en moet handmatig worden onderzocht.
+
+
Tijdstip: {{failed_at}}
+
Foutmelding: {{error_message}}
+
+
Controleer de serverlogboeken voor meer details.
`,
+ body_text: `Database-back-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}\n\nControleer de serverlogboeken voor meer details.`,
+ },
+ pt: {
+ subject: '[PicPeak] FALHA no backup do banco de dados',
+ body_html: `
Falha no backup do banco de dados
+
O backup agendado do banco de dados falhou e precisa de investigação manual.
+
+
Horário: {{failed_at}}
+
Erro: {{error_message}}
+
+
Verifique os logs do servidor para mais detalhes.
`,
+ body_text: `Falha no backup do banco de dados\n\nHorário: {{failed_at}}\nErro: {{error_message}}\n\nVerifique os logs do servidor.`,
+ },
+ ru: {
+ subject: '[PicPeak] ОШИБКА резервного копирования БД',
+ body_html: `
Ошибка резервного копирования базы данных
+
Запланированное резервное копирование базы данных завершилось с ошибкой и требует ручной проверки.
+
+
Время: {{failed_at}}
+
Ошибка: {{error_message}}
+
+
Проверьте журналы сервера для получения дополнительной информации.
`,
+ body_text: `Ошибка резервного копирования базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}\n\nПроверьте журналы сервера.`,
+ },
+ fr: {
+ subject: '[PicPeak] ÉCHEC de la sauvegarde de la base de données',
+ body_html: `
Échec de la sauvegarde de la base de données
+
La sauvegarde planifiée de la base de données a échoué et nécessite une investigation manuelle.
+
+
Heure : {{failed_at}}
+
Erreur : {{error_message}}
+
+
Consultez les journaux du serveur pour plus de détails.
`,
+ body_text: `Échec de la sauvegarde de la base de données\n\nHeure : {{failed_at}}\nErreur : {{error_message}}\n\nConsultez les journaux du serveur.`,
+ },
+ },
+
+ restore_completed: {
+ nl: {
+ subject: '[PicPeak] Database-herstel succesvol',
+ body_html: `
Database-herstel voltooid
+
De handmatige database-herstel-operatie is succesvol voltooid.
+
+
Tijdstip: {{completed_at}}
+
Herstelpunt: {{source_backup}}
+
`,
+ body_text: `Database-herstel voltooid\n\nTijdstip: {{completed_at}}\nHerstelpunt: {{source_backup}}`,
+ },
+ pt: {
+ subject: '[PicPeak] Restauração do banco de dados concluída',
+ body_html: `
Restauração concluída
+
A restauração manual do banco de dados foi concluída com sucesso.
Ручная операция восстановления базы данных успешно завершена.
+
+
Время: {{completed_at}}
+
Точка восстановления: {{source_backup}}
+
`,
+ body_text: `Восстановление базы данных завершено\n\nВремя: {{completed_at}}\nТочка восстановления: {{source_backup}}`,
+ },
+ fr: {
+ subject: '[PicPeak] Restauration de la base de données réussie',
+ body_html: `
Restauration de la base terminée
+
L'opération manuelle de restauration de la base de données s'est terminée avec succès.
Ручная операция восстановления базы данных завершилась с ошибкой и требует проверки.
+
+
Время: {{failed_at}}
+
Ошибка: {{error_message}}
+
`,
+ body_text: `Ошибка восстановления базы данных\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`,
+ },
+ fr: {
+ subject: '[PicPeak] ÉCHEC de la restauration de la base de données',
+ body_html: `
Échec de la restauration
+
L'opération manuelle de restauration de la base de données a échoué et nécessite une investigation.
+
+
Heure : {{failed_at}}
+
Erreur : {{error_message}}
+
`,
+ body_text: `Échec de la restauration\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`,
+ },
+ },
+
+ // ────────────────────────────────────────────────────────────────
+ // File backups (legacy backup_completed / backup_failed pair).
+ // Mostly identical content to database_backup_* but kept separate
+ // because the legacy keys are still wired to a different code path.
+ // ────────────────────────────────────────────────────────────────
+ backup_completed: {
+ nl: {
+ subject: '[PicPeak] Bestandsback-up voltooid',
+ body_html: `
Bestandsback-up voltooid
+
De geplande bestandsback-up is succesvol voltooid.
`,
+ body_text: `Nouvelle version de PicPeak disponible\n\nVersion actuelle : {{current_version}}\nNouvelle version : {{new_version}}\nCanal : {{channel}}\n\nNotes de version : {{release_url}}`,
+ },
+ },
+
+ // version_update_test was seeded by migration 087 in the legacy
+ // subject_en / body_html_en / subject_de / body_html_de columns
+ // AFTER migration 075 had already migrated existing rows into
+ // email_template_translations — so this template has zero
+ // translation rows even though the EN/DE content exists. The
+ // Templates admin UI consequently shows it as empty. Seeding the
+ // full set here (mirroring 087's curated EN/DE plus AI-generated
+ // nl/pt/ru/fr) restores the editor.
+ version_update_test: {
+ en: {
+ subject: '[TEST] PicPeak Update Notification — configuration check',
+ body_html: `
This is a test email
+
You are receiving this message because an administrator clicked
+Send Test Email on the Update Notifications page of your
+PicPeak installation.
+
+
Installed version: {{current_version}}
+
Channel: {{channel}}
+
Recipient address: {{recipient_email}}
+
+
If you can read this email, your SMTP configuration and the recipient
+list are working correctly. When a real new version becomes available,
+PicPeak will send a separate notification with release notes and update
+instructions.
+
No action is required.
+You may safely delete this message.
`,
+ body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`,
+ },
+ de: {
+ subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
+ body_html: `
Dies ist eine Test-E-Mail
+
Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
+„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
+Test-E-Mail senden geklickt hat.
+
+
Installierte Version: {{current_version}}
+
Kanal: {{channel}}
+
Empfänger-Adresse: {{recipient_email}}
+
+
Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
+und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
+ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
+und Update-Anweisungen.
+
Es ist keine Aktion
+erforderlich. Sie können diese Nachricht gefahrlos löschen.
`,
+ body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`,
+ },
+ nl: {
+ subject: '[TEST] PicPeak-update-melding — configuratiecontrole',
+ body_html: `
Dit is een test-e-mail
+
U ontvangt dit bericht omdat een beheerder op de pagina
+"Update-meldingen" van uw PicPeak-installatie op
+Test-e-mail verzenden heeft geklikt.
+
+
Geïnstalleerde versie: {{current_version}}
+
Kanaal: {{channel}}
+
Ontvangeradres: {{recipient_email}}
+
+
Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.
+
Geen actie vereist. U kunt dit bericht veilig verwijderen.
`,
+ body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`,
+ },
+ pt: {
+ subject: '[TESTE] Notificação de atualização do PicPeak — verificação',
+ body_html: `
Este é um e-mail de teste
+
Você está recebendo esta mensagem porque um administrador clicou em
+Enviar e-mail de teste na página "Notificações de
+atualização" da sua instalação do PicPeak.
+
+
Versão instalada: {{current_version}}
+
Canal: {{channel}}
+
Endereço do destinatário: {{recipient_email}}
+
+
Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.
+
Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.
`,
+ body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`,
+ },
+ ru: {
+ subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка',
+ body_html: `
Это тестовое письмо
+
Вы получили это сообщение, потому что администратор нажал
+Отправить тестовое письмо на странице
+«Уведомления об обновлениях» вашей установки PicPeak.
+
+
Установленная версия: {{current_version}}
+
Канал: {{channel}}
+
Адрес получателя: {{recipient_email}}
+
+
Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.
+
Никаких действий не требуется. Можете безопасно удалить это сообщение.
`,
+ body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`,
+ },
+ fr: {
+ subject: '[TEST] Notification de mise à jour PicPeak — vérification',
+ body_html: `
Ceci est un e-mail de test
+
Vous recevez ce message parce qu'un administrateur a cliqué sur
+Envoyer un e-mail de test sur la page « Notifications
+de mise à jour » de votre installation PicPeak.
+
+
Version installée : {{current_version}}
+
Canal : {{channel}}
+
Adresse du destinataire : {{recipient_email}}
+
+
Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.
+
Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.
`,
+ body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`,
+ },
+ },
+};
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+ if (!(await knex.schema.hasTable('email_template_translations'))) return;
+
+ // Resolve template_key → id once, skip keys that aren't seeded on
+ // this install (e.g. customer_invitation on a pre-090 instance).
+ const rows = await knex('email_templates')
+ .whereIn('template_key', Object.keys(TRANSLATIONS))
+ .select('id', 'template_key');
+ const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
+
+ let inserted = 0;
+ let skipped = 0;
+
+ for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
+ const templateId = keyToId[key];
+ if (!templateId) {
+ // Template not present on this install (older release than the
+ // seeding migration). Skip — there's nothing to attach to.
+ continue;
+ }
+
+ for (const [language, content] of Object.entries(perLocale)) {
+ const existing = await knex('email_template_translations')
+ .where({ template_id: templateId, language })
+ .first();
+ if (existing) {
+ skipped += 1;
+ continue;
+ }
+ await knex('email_template_translations').insert({
+ template_id: templateId,
+ language,
+ subject: content.subject,
+ body_html: content.body_html,
+ body_text: content.body_text,
+ created_at: new Date(),
+ updated_at: new Date(),
+ });
+ inserted += 1;
+ }
+ }
+
+ console.log(`099_seed_missing_email_template_translations: inserted=${inserted}, skipped=${skipped}`);
+};
+
+exports.down = async function(knex) {
+ // Down-migration intentionally a no-op. We don't know which of the
+ // locale rows existed before this migration vs were inserted by it —
+ // dropping every nl/pt/ru/fr row would wipe content the admin may
+ // have edited in the UI. Rollback by hand if you really need to.
+};
diff --git a/backend/migrations/core/100_backfill_email_template_subcategory.js b/backend/migrations/core/100_backfill_email_template_subcategory.js
new file mode 100644
index 00000000..bc8e7b56
--- /dev/null
+++ b/backend/migrations/core/100_backfill_email_template_subcategory.js
@@ -0,0 +1,295 @@
+/**
+ * Migration: Re-apply email-template backfills that earlier deployed
+ * versions of 098 / 099 missed.
+ *
+ * Why a separate migration? Knex tracks migrations by filename — once
+ * 098 / 099 were recorded as applied in `knex_migrations`, editing
+ * them doesn't re-run on subsequent deploys. The first deployed
+ * versions of those migrations didn't include:
+ * - the `subcategory` column population (added later)
+ * - the `customer_password_reset` category override (added later)
+ * - the en/de/nl/pt/ru/fr translation rows for
+ * `customer_password_reset` and `version_update_test` (both
+ * inserted after 075 ran, so they sat in legacy columns only
+ * and showed empty in the Templates editor — the AI translations
+ * were added to 099 after its first deploy).
+ *
+ * This migration is append-only (no schema change beyond defensive
+ * column checks) and re-applies all the affected data:
+ * 1. Sets category / subcategory / feature_flag on every known
+ * template_key.
+ * 2. Seeds missing translation rows for the two post-075
+ * templates across all six locales.
+ *
+ * Idempotent throughout: skips translation inserts that already
+ * exist, and the category writes are no-ops when values already match.
+ */
+
+const TEMPLATE_METADATA = {
+ // Core / Galleries — gallery delivery lifecycle.
+ gallery_created: { category: 'core', subcategory: 'gallery', feature_flag: null },
+ expiration_warning: { category: 'core', subcategory: 'gallery', feature_flag: null },
+ gallery_expired: { category: 'core', subcategory: 'gallery', feature_flag: null },
+ archive_complete: { category: 'core', subcategory: 'gallery', feature_flag: null },
+ // Core / Admin — admin account lifecycle.
+ admin_invitation: { category: 'core', subcategory: 'admin', feature_flag: null },
+ admin_password_reset: { category: 'core', subcategory: 'admin', feature_flag: null },
+ // Core / Backup — database + file backups + restores.
+ database_backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
+ database_backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
+ restore_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
+ restore_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
+ backup_completed: { category: 'core', subcategory: 'backup', feature_flag: null },
+ backup_failed: { category: 'core', subcategory: 'backup', feature_flag: null },
+ // Core / System — version-update notifications.
+ version_update_available: { category: 'core', subcategory: 'system', feature_flag: null },
+ version_update_test: { category: 'core', subcategory: 'system', feature_flag: null },
+ // Customers — customer-portal lifecycle.
+ customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
+ customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' },
+};
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+
+ // Defensive: if migration 098 didn't run for some reason on this
+ // install (a fork, a partial copy, etc.) make sure the columns
+ // exist before we try to write to them. Idempotent — these are
+ // no-ops if the column already exists.
+ const cols = await knex('email_templates').columnInfo();
+ if (!cols.category) {
+ await knex.schema.alterTable('email_templates', (t) => {
+ t.string('category', 32).notNullable().defaultTo('core');
+ });
+ }
+ if (!cols.subcategory) {
+ await knex.schema.alterTable('email_templates', (t) => {
+ t.string('subcategory', 32).nullable();
+ });
+ }
+ if (!cols.feature_flag) {
+ await knex.schema.alterTable('email_templates', (t) => {
+ t.string('feature_flag', 64).nullable();
+ });
+ }
+
+ let updated = 0;
+ for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) {
+ const result = await knex('email_templates')
+ .where({ template_key: key })
+ .update({
+ category: meta.category,
+ subcategory: meta.subcategory,
+ feature_flag: meta.feature_flag,
+ });
+ if (result > 0) updated += 1;
+ }
+ console.log(`100_backfill_email_template_subcategory: updated ${updated} template rows`);
+
+ // ── Translation backfill ──────────────────────────────────────────
+ // customer_password_reset (migration 092) and version_update_test
+ // (migration 087) were inserted AFTER migration 075 ran, so they
+ // have content in legacy subject_*/body_html_* columns but zero
+ // rows in `email_template_translations`. The Templates editor
+ // reads exclusively from the translations table → shows them as
+ // 0/6 empty until we seed them. Migration 099 added these rows on
+ // initial deploy, but the earlier-shipped version of 099 didn't
+ // include them, so instances that ran it then-and-now still have
+ // empty editors. Re-seed defensively here, skipping any
+ // (template_id, language) pair that already exists.
+ if (!(await knex.schema.hasTable('email_template_translations'))) return;
+
+ const TRANSLATIONS = {
+ customer_password_reset: {
+ en: {
+ subject: 'Reset your customer account password',
+ body_html: `
Hello,
+
Your photographer has triggered a password reset for your customer account.
If you didn't expect this, you can ignore the message — your current password keeps working until you click the link.
`,
+ body_text: `Reset your customer account password\n\nYour photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`,
+ },
+ de: {
+ subject: 'Passwort für dein Kundenkonto zurücksetzen',
+ body_html: `
Hallo,
+
Dein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.
Wenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.
`,
+ body_text: `Passwort für dein Kundenkonto zurücksetzen\n\nDein Fotograf hat einen Passwort-Reset für dein Kundenkonto ausgelöst.\n\nNeues Passwort festlegen: {{reset_link}}\n\nDieser Link läuft am {{expires_at}} ab.\n\nWenn du diese Anfrage nicht erwartet hast, kannst du diese Nachricht ignorieren — dein aktuelles Passwort funktioniert weiter, bis du den Link anklickst.`,
+ },
+ nl: {
+ subject: 'Wachtwoord van uw klantaccount opnieuw instellen',
+ body_html: `
Hallo,
+
Uw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.
Heeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.
`,
+ body_text: `Wachtwoord opnieuw instellen\n\nUw fotograaf heeft een wachtwoordreset voor uw klantaccount aangevraagd.\n\nNieuw wachtwoord instellen: {{reset_link}}\n\nDeze link verloopt op {{expires_at}}.\n\nHeeft u deze aanvraag niet verwacht? U kunt dit bericht negeren — uw huidige wachtwoord blijft werken totdat u op de link klikt.`,
+ },
+ pt: {
+ subject: 'Redefina a senha da sua conta de cliente',
+ body_html: `
Olá,
+
Seu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.
Se você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.
`,
+ body_text: `Redefinir senha\n\nSeu fotógrafo iniciou uma redefinição de senha para sua conta de cliente.\n\nDefinir nova senha: {{reset_link}}\n\nEste link expira em {{expires_at}}.\n\nSe você não esperava esta solicitação, pode ignorar a mensagem — sua senha atual continuará funcionando até você clicar no link.`,
+ },
+ ru: {
+ subject: 'Сброс пароля вашей клиентской учётной записи',
+ body_html: `
Здравствуйте!
+
Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.
Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.
`,
+ body_text: `Сброс пароля\n\nВаш фотограф инициировал сброс пароля для вашей клиентской учётной записи.\n\nЗадать новый пароль: {{reset_link}}\n\nСрок действия ссылки истекает {{expires_at}}.\n\nЕсли вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.`,
+ },
+ fr: {
+ subject: 'Réinitialisez le mot de passe de votre compte client',
+ body_html: `
Bonjour,
+
Votre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.
Si vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.
`,
+ body_text: `Réinitialiser le mot de passe\n\nVotre photographe a déclenché une réinitialisation de mot de passe pour votre compte client.\n\nDéfinir un nouveau mot de passe : {{reset_link}}\n\nCe lien expire le {{expires_at}}.\n\nSi vous n'attendiez pas cette demande, vous pouvez ignorer ce message — votre mot de passe actuel continue de fonctionner jusqu'à ce que vous cliquiez sur le lien.`,
+ },
+ },
+ version_update_test: {
+ en: {
+ subject: '[TEST] PicPeak Update Notification — configuration check',
+ body_html: `
This is a test email
+
You are receiving this message because an administrator clicked
+Send Test Email on the Update Notifications page of your
+PicPeak installation.
+
+
Installed version: {{current_version}}
+
Channel: {{channel}}
+
Recipient address: {{recipient_email}}
+
+
If you can read this email, your SMTP configuration and the recipient
+list are working correctly. When a real new version becomes available,
+PicPeak will send a separate notification with release notes and update
+instructions.
+
No action is required.
+You may safely delete this message.
`,
+ body_text: `This is a test email\n\nYou are receiving this message because an administrator clicked "Send Test Email" on the Update Notifications page of your PicPeak installation.\n\nInstalled version: {{current_version}}\nChannel: {{channel}}\nRecipient address: {{recipient_email}}\n\nIf you can read this email, your SMTP configuration and the recipient list are working correctly. When a real new version becomes available, PicPeak will send a separate notification with release notes and update instructions.\n\nNo action is required. You may safely delete this message.`,
+ },
+ de: {
+ subject: '[TEST] PicPeak Update-Benachrichtigung — Konfigurationsprüfung',
+ body_html: `
Dies ist eine Test-E-Mail
+
Sie erhalten diese Nachricht, weil ein Administrator auf der Seite
+„Update-Benachrichtigungen" Ihrer PicPeak-Installation auf
+Test-E-Mail senden geklickt hat.
+
+
Installierte Version: {{current_version}}
+
Kanal: {{channel}}
+
Empfänger-Adresse: {{recipient_email}}
+
+
Wenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration
+und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar
+ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen
+und Update-Anweisungen.
+
Es ist keine Aktion
+erforderlich. Sie können diese Nachricht gefahrlos löschen.
`,
+ body_text: `Dies ist eine Test-E-Mail\n\nSie erhalten diese Nachricht, weil ein Administrator auf der Seite „Update-Benachrichtigungen" Ihrer PicPeak-Installation auf „Test-E-Mail senden" geklickt hat.\n\nInstallierte Version: {{current_version}}\nKanal: {{channel}}\nEmpfänger-Adresse: {{recipient_email}}\n\nWenn Sie diese E-Mail lesen können, funktionieren Ihre SMTP-Konfiguration und die Empfängerliste korrekt. Sobald eine echte neue Version verfügbar ist, sendet PicPeak eine separate Benachrichtigung mit Versionshinweisen und Update-Anweisungen.\n\nEs ist keine Aktion erforderlich. Sie können diese Nachricht gefahrlos löschen.`,
+ },
+ nl: {
+ subject: '[TEST] PicPeak-update-melding — configuratiecontrole',
+ body_html: `
Dit is een test-e-mail
+
U ontvangt dit bericht omdat een beheerder op de pagina
+"Update-meldingen" van uw PicPeak-installatie op
+Test-e-mail verzenden heeft geklikt.
+
+
Geïnstalleerde versie: {{current_version}}
+
Kanaal: {{channel}}
+
Ontvangeradres: {{recipient_email}}
+
+
Als u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct. Wanneer er een echte nieuwe versie beschikbaar komt, stuurt PicPeak een aparte melding met release-notities en update-instructies.
+
Geen actie vereist. U kunt dit bericht veilig verwijderen.
`,
+ body_text: `Dit is een test-e-mail\n\nU ontvangt dit bericht omdat een beheerder op de pagina "Update-meldingen" van uw PicPeak-installatie op "Test-e-mail verzenden" heeft geklikt.\n\nGeïnstalleerde versie: {{current_version}}\nKanaal: {{channel}}\nOntvangeradres: {{recipient_email}}\n\nAls u deze e-mail kunt lezen, werken uw SMTP-configuratie en de ontvangerslijst correct.\n\nGeen actie vereist.`,
+ },
+ pt: {
+ subject: '[TESTE] Notificação de atualização do PicPeak — verificação',
+ body_html: `
Este é um e-mail de teste
+
Você está recebendo esta mensagem porque um administrador clicou em
+Enviar e-mail de teste na página "Notificações de
+atualização" da sua instalação do PicPeak.
+
+
Versão instalada: {{current_version}}
+
Canal: {{channel}}
+
Endereço do destinatário: {{recipient_email}}
+
+
Se você consegue ler este e-mail, sua configuração SMTP e a lista de destinatários estão funcionando corretamente. Quando uma nova versão real estiver disponível, o PicPeak enviará uma notificação separada com notas de versão e instruções de atualização.
+
Nenhuma ação é necessária. Você pode excluir esta mensagem com segurança.
`,
+ body_text: `Este é um e-mail de teste\n\nVocê está recebendo esta mensagem porque um administrador clicou em "Enviar e-mail de teste" na página "Notificações de atualização" da sua instalação do PicPeak.\n\nVersão instalada: {{current_version}}\nCanal: {{channel}}\nEndereço do destinatário: {{recipient_email}}\n\nSe você consegue ler este e-mail, sua configuração SMTP está funcionando corretamente.\n\nNenhuma ação é necessária.`,
+ },
+ ru: {
+ subject: '[ТЕСТ] Уведомление об обновлениях PicPeak — проверка',
+ body_html: `
Это тестовое письмо
+
Вы получили это сообщение, потому что администратор нажал
+Отправить тестовое письмо на странице
+«Уведомления об обновлениях» вашей установки PicPeak.
+
+
Установленная версия: {{current_version}}
+
Канал: {{channel}}
+
Адрес получателя: {{recipient_email}}
+
+
Если вы видите это письмо, значит ваша конфигурация SMTP и список получателей работают корректно. Когда станет доступна новая версия, PicPeak отправит отдельное уведомление с примечаниями к выпуску и инструкциями по обновлению.
+
Никаких действий не требуется. Можете безопасно удалить это сообщение.
`,
+ body_text: `Это тестовое письмо\n\nВы получили это сообщение, потому что администратор нажал «Отправить тестовое письмо» на странице «Уведомления об обновлениях» вашей установки PicPeak.\n\nУстановленная версия: {{current_version}}\nКанал: {{channel}}\nАдрес получателя: {{recipient_email}}\n\nЕсли вы видите это письмо, ваша конфигурация SMTP работает корректно.\n\nНикаких действий не требуется.`,
+ },
+ fr: {
+ subject: '[TEST] Notification de mise à jour PicPeak — vérification',
+ body_html: `
Ceci est un e-mail de test
+
Vous recevez ce message parce qu'un administrateur a cliqué sur
+Envoyer un e-mail de test sur la page « Notifications
+de mise à jour » de votre installation PicPeak.
+
+
Version installée : {{current_version}}
+
Canal : {{channel}}
+
Adresse du destinataire : {{recipient_email}}
+
+
Si vous pouvez lire cet e-mail, votre configuration SMTP et la liste des destinataires fonctionnent correctement. Lorsqu'une nouvelle version réelle sera disponible, PicPeak enverra une notification distincte avec les notes de version et les instructions de mise à jour.
+
Aucune action n'est requise. Vous pouvez supprimer ce message en toute sécurité.
`,
+ body_text: `Ceci est un e-mail de test\n\nVous recevez ce message parce qu'un administrateur a cliqué sur « Envoyer un e-mail de test » sur la page « Notifications de mise à jour » de votre installation PicPeak.\n\nVersion installée : {{current_version}}\nCanal : {{channel}}\nAdresse du destinataire : {{recipient_email}}\n\nSi vous pouvez lire cet e-mail, votre configuration SMTP fonctionne correctement.\n\nAucune action n'est requise.`,
+ },
+ },
+ };
+
+ const keyRows = await knex('email_templates')
+ .whereIn('template_key', Object.keys(TRANSLATIONS))
+ .select('id', 'template_key');
+ const keyToId = Object.fromEntries(keyRows.map((r) => [r.template_key, r.id]));
+
+ let inserted = 0;
+ let skipped = 0;
+ for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
+ const templateId = keyToId[key];
+ if (!templateId) continue;
+ for (const [language, content] of Object.entries(perLocale)) {
+ const existing = await knex('email_template_translations')
+ .where({ template_id: templateId, language })
+ .first();
+ if (existing) { skipped += 1; continue; }
+ await knex('email_template_translations').insert({
+ template_id: templateId,
+ language,
+ subject: content.subject,
+ body_html: content.body_html,
+ body_text: content.body_text,
+ created_at: new Date(),
+ updated_at: new Date(),
+ });
+ inserted += 1;
+ }
+ }
+ console.log(`100_backfill_email_template_subcategory: translation rows inserted=${inserted}, skipped=${skipped}`);
+};
+
+exports.down = async function() {
+ // No-op. This migration is a data backfill — rolling it back would
+ // require restoring the previous values, which we don't track.
+ // Migration 098's down handler still owns dropping the columns.
+};
diff --git a/backend/migrations/core/101_add_customer_gallery_assigned_template.js b/backend/migrations/core/101_add_customer_gallery_assigned_template.js
new file mode 100644
index 00000000..557c400b
--- /dev/null
+++ b/backend/migrations/core/101_add_customer_gallery_assigned_template.js
@@ -0,0 +1,243 @@
+/**
+ * Migration: Add `customer_gallery_assigned` email template.
+ *
+ * Sent when an admin adds new gallery assignments to an existing
+ * customer via the "Manage galleries" dialog on the customer detail
+ * page. Digest-style — one email per save listing every newly added
+ * gallery, not one email per gallery (admins often set up new clients
+ * by adding several galleries in a single sitting).
+ *
+ * Variables:
+ * - customer_name greeting name (display name / first name / email local)
+ * - gallery_count integer (string) — number of newly added galleries
+ * - singular "true" when count === 1 (drives intro wording)
+ * - multiple "true" when count > 1
+ * - gallery_list_html pre-rendered
with names + dates; passes
+ * through unescaped because the service builds it
+ * from trusted DB fields (event_name from
+ * admin-owned rows + server-rendered dates).
+ * - gallery_list_text newline-separated plain-text equivalent for
+ * the text/plain body.
+ * - dashboard_link URL of /customer/dashboard on the configured
+ * frontend origin.
+ *
+ * Category + flag: 'customers' + customerPortal — categorisation
+ * scaffold from migration 098. When the customer portal flag is off,
+ * the Templates admin UI chips this card "Feature off" but it's still
+ * editable.
+ *
+ * Translations: en + de hand-translated; nl/pt/ru/fr machine-generated
+ * and flagged for native review per project convention.
+ *
+ * Idempotent: skips if the template_key already exists.
+ */
+
+const TRANSLATIONS = {
+ en: {
+ subject: 'New gallery access on your account',
+ body_html: `
You have new gallery access
+
Hi {{customer_name}},
+{{#if singular}}
Your photographer just gave you access to a new gallery on your account:
{{/if}}{{#if multiple}}
Your photographer just gave you access to {{gallery_count}} new galleries on your account:
If the button doesn't work, copy and paste this link into your browser:
+{{dashboard_link}}
`,
+ body_text: `You have new gallery access
+
+Hi {{customer_name}},
+
+Your photographer just gave you access to {{gallery_count}} new gallery (or galleries) on your account:
+
+{{gallery_list_text}}
+
+Open your dashboard: {{dashboard_link}}`,
+ },
+ de: {
+ subject: 'Neue Galerie in deinem Konto verfügbar',
+ body_html: `
Du hast Zugriff auf neue Galerien
+
Hallo {{customer_name}},
+{{#if singular}}
Dein Fotograf hat dir gerade Zugriff auf eine neue Galerie in deinem Konto gegeben:
{{/if}}{{#if multiple}}
Dein Fotograf hat dir gerade Zugriff auf {{gallery_count}} neue Galerien in deinem Konto gegeben:
Werkt de knop niet? Kopieer dan deze link in uw browser:
+{{dashboard_link}}
`,
+ body_text: `U heeft toegang tot nieuwe galerijen
+
+Hallo {{customer_name}},
+
+Uw fotograaf heeft u zojuist toegang gegeven tot {{gallery_count}} nieuwe galerij(en) in uw account:
+
+{{gallery_list_text}}
+
+Dashboard: {{dashboard_link}}`,
+ },
+ pt: {
+ subject: 'Nova galeria disponível em sua conta',
+ body_html: `
Você tem acesso a novas galerias
+
Olá {{customer_name}},
+{{#if singular}}
Seu fotógrafo acabou de lhe dar acesso a uma nova galeria em sua conta:
{{/if}}{{#if multiple}}
Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} novas galerias em sua conta:
Se o botão não funcionar, copie este link no navegador:
+{{dashboard_link}}
`,
+ body_text: `Você tem acesso a novas galerias
+
+Olá {{customer_name}},
+
+Seu fotógrafo acabou de lhe dar acesso a {{gallery_count}} nova(s) galeria(s) em sua conta:
+
+{{gallery_list_text}}
+
+Painel: {{dashboard_link}}`,
+ },
+ ru: {
+ subject: 'Новая галерея доступна в вашем аккаунте',
+ body_html: `
У вас новый доступ к галереям
+
Здравствуйте, {{customer_name}}!
+{{#if singular}}
Ваш фотограф только что предоставил вам доступ к новой галерее в вашем аккаунте:
{{/if}}{{#if multiple}}
Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
Если кнопка не работает, скопируйте эту ссылку в браузер:
+{{dashboard_link}}
`,
+ body_text: `У вас новый доступ к галереям
+
+Здравствуйте, {{customer_name}}!
+
+Ваш фотограф только что предоставил вам доступ к {{gallery_count}} новым галереям в вашем аккаунте:
+
+{{gallery_list_text}}
+
+Личный кабинет: {{dashboard_link}}`,
+ },
+};
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+
+ const existing = await knex('email_templates')
+ .where({ template_key: 'customer_gallery_assigned' })
+ .first();
+ if (existing) {
+ console.log(' customer_gallery_assigned template already exists, skipping insert');
+ return;
+ }
+
+ // Detect schema variant (legacy per-column vs normalized translations).
+ // Newer installs have the email_template_translations table from
+ // migration 075; older ones might still have subject_en/de/... columns
+ // and NOT NULL constraints on the legacy columns. Cover both.
+ const cols = await knex('email_templates').columnInfo();
+ const hasTranslationsTable = await knex.schema.hasTable('email_template_translations');
+
+ const enContent = TRANSLATIONS.en;
+
+ // Build the master row. category/subcategory/feature_flag columns
+ // were added in migration 098 — guard so this migration works on
+ // a slightly older install too.
+ const masterRow = {
+ template_key: 'customer_gallery_assigned',
+ variables: JSON.stringify([
+ 'customer_name',
+ 'gallery_count',
+ 'singular',
+ 'multiple',
+ 'gallery_list_html',
+ 'gallery_list_text',
+ 'dashboard_link',
+ ]),
+ };
+ if ('category' in cols) masterRow.category = 'customers';
+ if ('subcategory' in cols) masterRow.subcategory = null;
+ if ('feature_flag' in cols) masterRow.feature_flag = 'customerPortal';
+ if ('created_at' in cols) masterRow.created_at = new Date();
+ if ('updated_at' in cols) masterRow.updated_at = new Date();
+
+ // Populate any legacy subject_*/body_html_*/body_text_* columns the
+ // schema still carries. Fallback content for non-en locales is the
+ // English string — the translations table below has the real
+ // per-locale copy. This only matters if the install hasn't run
+ // migration 075 yet, which is rare but possible.
+ for (const colName of Object.keys(cols)) {
+ if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
+ masterRow[colName] = enContent.subject;
+ } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
+ masterRow[colName] = enContent.body_html;
+ } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
+ masterRow[colName] = enContent.body_text;
+ }
+ }
+
+ const inserted = await knex('email_templates').insert(masterRow).returning('id');
+ const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
+
+ if (hasTranslationsTable && templateId) {
+ for (const [language, content] of Object.entries(TRANSLATIONS)) {
+ await knex('email_template_translations').insert({
+ template_id: templateId,
+ language,
+ subject: content.subject,
+ body_html: content.body_html,
+ body_text: content.body_text,
+ created_at: new Date(),
+ updated_at: new Date(),
+ });
+ }
+ }
+
+ console.log(' customer_gallery_assigned template inserted with 6 translations');
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+ await knex('email_templates').where({ template_key: 'customer_gallery_assigned' }).del();
+};
diff --git a/backend/migrations/core/102_add_og_image_share_enabled.js b/backend/migrations/core/102_add_og_image_share_enabled.js
new file mode 100644
index 00000000..48bf2a71
--- /dev/null
+++ b/backend/migrations/core/102_add_og_image_share_enabled.js
@@ -0,0 +1,43 @@
+/**
+ * Migration: Per-event opt-in for using the gallery hero photo as the
+ * Open Graph share image (#474).
+ *
+ * Background: galleryOgService already serves OG/Twitter Card meta
+ * tags to social-crawler User-Agents (WhatsApp, Facebook, Slack,
+ * Telegram, Discord, etc.) for /gallery/ URLs — see
+ * frontend/nginx.conf and backend/src/services/galleryOgService.js.
+ * Today the og:image is always the brand logo, with the inline
+ * rationale "no protected photo content."
+ *
+ * #474 asks for a hero/cover photo preview. The trade-off is that
+ * the og:image is fetched unauthenticated by every link-preview
+ * crawler, so any opted-in image is effectively public. We ship
+ * this as a per-event boolean, default FALSE, so existing galleries
+ * never start surfacing photos until the admin consciously flips it
+ * on per gallery.
+ *
+ * When set to TRUE and the event has a hero_photo_id with a
+ * generated thumbnail, galleryOgService points og:image at the new
+ * /og/gallery/:slug/cover endpoint. With it set to FALSE (or no
+ * hero photo selected) the brand logo is used as before.
+ *
+ * Idempotent: re-runs are no-ops.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('events'))) return;
+ if (await knex.schema.hasColumn('events', 'og_image_share_enabled')) return;
+ await knex.schema.alterTable('events', (table) => {
+ // Default false everywhere so an upgrade never starts leaking the
+ // hero photo of a password-protected gallery without admin intent.
+ table.boolean('og_image_share_enabled').notNullable().defaultTo(false);
+ });
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('events'))) return;
+ if (!(await knex.schema.hasColumn('events', 'og_image_share_enabled'))) return;
+ await knex.schema.alterTable('events', (table) => {
+ table.dropColumn('og_image_share_enabled');
+ });
+};
diff --git a/backend/migrations/core/103_add_promo_alignment_setting.js b/backend/migrations/core/103_add_promo_alignment_setting.js
new file mode 100644
index 00000000..143341ff
--- /dev/null
+++ b/backend/migrations/core/103_add_promo_alignment_setting.js
@@ -0,0 +1,43 @@
+/**
+ * Migration: Promotional banner text alignment (#482).
+ *
+ * Adds `branding_promo_alignment` to app_settings — admin-controlled
+ * horizontal alignment for the gallery promotional banner content
+ * (#440 / #482). Reuses the existing app_settings shape that
+ * branding_promo_markdown / branding_promo_position already use.
+ *
+ * Default 'center' so the banner aligns with the gallery footer
+ * (which is full-width center-aligned). The previous default left
+ * the markdown left-aligned in a max-w-3xl block, which Rekoo-PS
+ * reported as visually offset from the footer.
+ *
+ * Allowed values: 'left' | 'center' | 'right' — validated on the
+ * write path in adminSettings.js, not enforced by the column type
+ * (we use varchar instead of CHECK so the value can be extended
+ * later — e.g. 'justify' — without another schema migration).
+ *
+ * Idempotent: skips the insert when the row already exists.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('app_settings'))) return;
+
+ const existing = await knex('app_settings')
+ .where('setting_key', 'branding_promo_alignment')
+ .first();
+ if (existing) return;
+
+ await knex('app_settings').insert({
+ setting_key: 'branding_promo_alignment',
+ setting_value: JSON.stringify('center'),
+ setting_type: 'branding',
+ updated_at: new Date(),
+ });
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('app_settings'))) return;
+ await knex('app_settings')
+ .where('setting_key', 'branding_promo_alignment')
+ .del();
+};
diff --git a/backend/migrations/core/104_add_lightbox_preview_tier.js b/backend/migrations/core/104_add_lightbox_preview_tier.js
new file mode 100644
index 00000000..92114345
--- /dev/null
+++ b/backend/migrations/core/104_add_lightbox_preview_tier.js
@@ -0,0 +1,59 @@
+/**
+ * Migration: Lightbox medium-resolution preview tier (#492).
+ *
+ * Adds:
+ * - photos.preview_path (nullable VARCHAR) — storage key for the
+ * per-photo preview JPEG; populated lazily by ensurePreviewImage
+ * on first lightbox open (or eagerly by the regenerate-previews
+ * admin endpoint). Mirrors photos.thumbnail_path / hero_path.
+ * - app_settings.lightbox_preview_enabled (boolean, default false)
+ * — opt-in toggle. Off by default because the new tier costs
+ * ~200–500 KB per photo on disk; admins flip it on once they've
+ * decided the perf win is worth the storage.
+ *
+ * No backfill of existing photos here — preview generation is lazy
+ * by design and a separate "Regenerate previews" admin button covers
+ * eager backfill when an admin wants to warm the cache for an
+ * existing gallery.
+ *
+ * Idempotent: every step checks for existing state.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('photos'))) return;
+
+ if (!(await knex.schema.hasColumn('photos', 'preview_path'))) {
+ await knex.schema.alterTable('photos', (table) => {
+ table.string('preview_path');
+ });
+ }
+
+ if (!(await knex.schema.hasTable('app_settings'))) return;
+ const existing = await knex('app_settings')
+ .where('setting_key', 'lightbox_preview_enabled')
+ .first();
+ if (!existing) {
+ await knex('app_settings').insert({
+ setting_key: 'lightbox_preview_enabled',
+ // SQLite stores TEXT, Postgres JSONB — JSON-stringify so both
+ // backends round-trip a recognisable boolean shape, matching
+ // how other branding_* boolean settings are stored today.
+ setting_value: JSON.stringify(false),
+ setting_type: 'thumbnail',
+ updated_at: new Date(),
+ });
+ }
+};
+
+exports.down = async function(knex) {
+ if (await knex.schema.hasTable('app_settings')) {
+ await knex('app_settings')
+ .where('setting_key', 'lightbox_preview_enabled')
+ .del();
+ }
+ if (await knex.schema.hasTable('photos') && await knex.schema.hasColumn('photos', 'preview_path')) {
+ await knex.schema.alterTable('photos', (table) => {
+ table.dropColumn('preview_path');
+ });
+ }
+};
diff --git a/backend/migrations/core/105_fix_backup_runs_indexes.js b/backend/migrations/core/105_fix_backup_runs_indexes.js
new file mode 100644
index 00000000..7d3d6e90
--- /dev/null
+++ b/backend/migrations/core/105_fix_backup_runs_indexes.js
@@ -0,0 +1,55 @@
+/**
+ * Migration: back-fix the backup_runs indexes that migration 035 tried to
+ * create on the nonexistent `created_at` column (#484).
+ *
+ * On Postgres, 035's `CREATE INDEX ... ON backup_runs(created_at, ...)`
+ * statements raised `column "created_at" does not exist`, which was caught
+ * silently by the wrapping try/catch — so the migration "succeeded" but the
+ * indexes never got created. Fresh installs saw the ERROR in the postgres
+ * log; existing installs simply ran without those indexes.
+ *
+ * 035 has now been corrected to use `started_at` (the column that does
+ * exist on backup_runs and carries the same chronological semantics).
+ * This migration creates the same indexes idempotently for any deployment
+ * whose 035 silently failed — no-op on fresh installs because 035 already
+ * built them.
+ *
+ * SQLite: partial indexes (`WHERE …`) work but cross-table semantics differ
+ * slightly from Postgres; we still emit them because the only consumer is
+ * the backup-history query in `backupService` and it issues identical SQL
+ * across both backends.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('backup_runs'))) return;
+ if (!(await knex.schema.hasColumn('backup_runs', 'started_at'))) return;
+
+ // Plain composite index — matches the corrected statement in 035.
+ await knex.raw(
+ 'CREATE INDEX IF NOT EXISTS idx_backup_runs_started_mode ON backup_runs(started_at, backup_mode)'
+ );
+
+ // Partial indexes only get created if backup_mode exists (035 added it).
+ if (!(await knex.schema.hasColumn('backup_runs', 'backup_mode'))) return;
+
+ await knex.raw(`
+ CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
+ ON backup_runs(started_at DESC)
+ WHERE status = 'completed' AND backup_mode = 'full'
+ `);
+
+ if (await knex.schema.hasColumn('backup_runs', 'parent_backup_id')) {
+ await knex.raw(`
+ CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
+ ON backup_runs(parent_backup_id, started_at)
+ WHERE backup_mode = 'incremental'
+ `);
+ }
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('backup_runs'))) return;
+ await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_started_mode');
+ await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful');
+ await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain');
+};
diff --git a/backend/migrations/core/106_seed_es_email_template_translations.js b/backend/migrations/core/106_seed_es_email_template_translations.js
new file mode 100644
index 00000000..06db0e78
--- /dev/null
+++ b/backend/migrations/core/106_seed_es_email_template_translations.js
@@ -0,0 +1,121 @@
+/**
+ * Migration: seed Spanish (es) email-template translations (#510).
+ *
+ * Contributed by @AloePacci on issue #510. Covers the four
+ * customer-facing gallery delivery templates that already had
+ * en/de/nl/pt/ru rows from migration 075. Templates without an `es`
+ * row (admin_*, backup_*, restore_*, customer_*, version_update_*)
+ * continue to fall back to `en` via the resolution chain in
+ * emailProcessor.processTemplate — no functional gap, just untranslated
+ * copy until someone fills them in.
+ *
+ * Same idempotency pattern as 099_seed_missing_email_template_translations:
+ * checks (template_id, language) before inserting so re-runs are safe.
+ */
+
+const TRANSLATIONS = {
+ gallery_created: {
+ es: {
+ subject: 'Su galería de fotos está lista!',
+ body_html: `
Galería creada con éxito
+
Estimado {{host_name}},
+
Su galería de fotos "{{event_name}}" ha sido creada con éxito!