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.

+
+ Set up your account +
+

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.

+
+ Konto einrichten +
+

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.

+

Click here to set a new password. This link expires on {{expires_at}}.

+

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.

+
+ Set up your account +
+

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.

`; + await knex('email_template_translations') + .where({ id: enRow.id }) + .update({ body_html: newHtml, updated_at: new Date() }); + } + + // German translation + const deRow = await knex('email_template_translations') + .where({ template_id: master.id, language: 'de' }) + .first(); + if (deRow && typeof deRow.body_html === 'string' + && deRow.body_html.includes('background-color: #5C8762') + && deRow.body_html.includes('Konto einrichten')) { + const newHtml = ` +

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 +
+

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.

`; + await knex('email_template_translations') + .where({ id: deRow.id }) + .update({ body_html: newHtml, updated_at: new Date() }); + } + + // Legacy single-language column on the master row, if present. + // Older installs may also have a hardcoded body_html on email_templates + // itself (pre-translations table). Same idempotent rewrite logic. + if (typeof master.body_html === 'string' + && master.body_html.includes('background-color: #5C8762')) { + await knex('email_templates') + .where({ id: master.id }) + .update({ + body_html: '

You\'ve been invited to create a customer account. Set up your account (expires {{expires_at}}).

', + updated_at: new Date(), + }); + } +}; + +exports.down = async function(/* knex */) { + // No-op: rolling back the visual fix would intentionally restore the + // bug. Admins who want the old green button can edit the template + // from Settings → Email Templates. +}; diff --git a/backend/migrations/core/095_add_customer_portal_flag.js b/backend/migrations/core/095_add_customer_portal_flag.js new file mode 100644 index 00000000..d36c97ad --- /dev/null +++ b/backend/migrations/core/095_add_customer_portal_flag.js @@ -0,0 +1,47 @@ +/** + * Migration 095: Add `customerPortal` to feature_flags. + * + * The customer portal (#354) is the foundation feature for the + * customer-side UI surface — login, dashboard, profile, password reset, + * and the admin Customers management page. Subordinate flags + * (calendar, calendarBooking, quotes, bills, messaging) are already + * present in the table from migration 088 and gate the customer-side + * tabs that hang off the dashboard. + * + * Default seeding rule mirrors 088: + * - Existing install (events table has rows) → customerPortal = TRUE. + * The PR ships with the customer-portal foundation already wired, + * so an admin who upgrades shouldn't see admin sidebar entries + * vanish until they explicitly opt out from Settings → Features. + * - Fresh install (no events) → customerPortal = FALSE. Picpeak still + * ships as a focused gallery delivery tool by default; admins flip + * this on when they want recurring-customer logins. + * + * Idempotent: skips the insert when the row already exists. Re-running + * is a no-op. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + + const existing = await knex('feature_flags').where({ key: 'customerPortal' }).first(); + if (existing) return; + + // Same existing-vs-fresh detection 088 uses — count events. The flag + // table is shared with 088's seeded keys; re-running detection keeps + // each new feature flag in lockstep with the install state instead + // of guessing per migration. + const eventCountRow = await knex('events').count({ count: '*' }).first(); + const eventCount = parseInt(eventCountRow?.count || 0, 10); + const isExistingInstall = eventCount > 0; + + await knex('feature_flags').insert({ + key: 'customerPortal', + value: isExistingInstall, + }); +}; + +exports.down = async function(knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + await knex('feature_flags').where({ key: 'customerPortal' }).del(); +}; diff --git a/backend/migrations/core/096_backfill_photo_dimensions_v2.js b/backend/migrations/core/096_backfill_photo_dimensions_v2.js new file mode 100644 index 00000000..dd2fd0eb --- /dev/null +++ b/backend/migrations/core/096_backfill_photo_dimensions_v2.js @@ -0,0 +1,102 @@ +/** + * Migration: Backfill photo dimensions (v2) + * + * Re-runs the dimension backfill from migration 064 for any rows that are + * still NULL. Migration 064 only ran once at upgrade time; new photos + * imported via fileWatcher.js or s3AutoImporter.js between then and now + * had their width/height columns left NULL because those code paths did + * not capture metadata on insert. This PR fixes both writers, but + * pre-existing rows still need a backfill — that is what this does. + * + * Without dimensions, MasonryGalleryLayout falls back to a hard-coded + * 800×600 default, which is why every card in masonry mode looks like + * the same 4:3 box (#447). + * + * Local-fs only — S3 deployments cannot read source objects in a + * migration without instantiating the storage backend. Those + * deployments rely on the writer fix in s3AutoImporter.js for new + * photos and can run a one-shot script if a backfill is needed. + */ + +const path = require('path'); +const fs = require('fs'); + +exports.up = async function(knex) { + const hasWidth = await knex.schema.hasColumn('photos', 'width'); + const hasHeight = await knex.schema.hasColumn('photos', 'height'); + if (!hasWidth || !hasHeight) { + console.log('[Migration 090] width/height columns not present, skipping'); + return; + } + + const backend = (process.env.STORAGE_BACKEND || 'local').toLowerCase(); + if (backend !== 'local') { + console.log(`[Migration 090] STORAGE_BACKEND=${backend} — backfill skipped (S3 deployments not supported in-migration)`); + return; + } + + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + + const photos = await knex('photos') + .where(function () { + this.whereNull('width').orWhereNull('height'); + }) + .andWhere(function () { + // Skip videos — sharp can't handle them; they need ffprobe. + this.where('media_type', '!=', 'video').orWhereNull('media_type'); + }) + .select('id', 'path', 'filename'); + + if (photos.length === 0) { + console.log('[Migration 090] no photos missing dimensions'); + return; + } + + console.log(`[Migration 090] backfilling ${photos.length} photos`); + + let sharp; + try { + sharp = require('sharp'); + } catch (err) { + console.error('[Migration 090] sharp unavailable, skipping:', err.message); + return; + } + + let updated = 0; + let failed = 0; + for (const photo of photos) { + try { + if (!photo.path) { + failed++; + continue; + } + const fullPath = path.join(storagePath, 'events/active', photo.path); + if (!fs.existsSync(fullPath)) { + failed++; + continue; + } + const metadata = await sharp(fullPath).metadata(); + if (metadata.width && metadata.height) { + await knex('photos').where('id', photo.id).update({ + width: metadata.width, + height: metadata.height, + }); + updated++; + if (updated % 100 === 0) { + console.log(`[Migration 090] ${updated}/${photos.length}`); + } + } else { + failed++; + } + } catch (err) { + console.error(`[Migration 090] photo ${photo.id}: ${err.message}`); + failed++; + } + } + + console.log(`[Migration 090] done — ${updated} updated, ${failed} skipped`); +}; + +exports.down = async function() { + // Data-only migration; no rollback action. +}; diff --git a/backend/migrations/core/097_add_clients_feature_flag.js b/backend/migrations/core/097_add_clients_feature_flag.js new file mode 100644 index 00000000..86e384d4 --- /dev/null +++ b/backend/migrations/core/097_add_clients_feature_flag.js @@ -0,0 +1,38 @@ +/** + * Migration: Add the `clients` top-level feature flag. + * + * Introduces a parent flag for the "Clients" sidebar section, which + * groups customer accounts today and will host calendar / quotes / + * bills / messaging in future PRs. The existing `customerPortal` flag + * is unchanged and continues to gate the /customer/* surface plus the + * Accounts sub-page; it now lives logically beneath `clients` in the + * Features tab. + * + * Initial value: mirrors the install's current `customerPortal` value + * so an admin who had the customer portal enabled keeps seeing the + * Clients sidebar entry after upgrade, and an admin who had it off + * doesn't suddenly see a new sidebar entry. + * + * Idempotent: re-runs are no-ops. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + + const existing = await knex('feature_flags').where({ key: 'clients' }).first(); + if (existing) return; + + const portalRow = await knex('feature_flags').where({ key: 'customerPortal' }).first(); + let initialValue = false; + if (portalRow) { + const raw = portalRow.value; + initialValue = raw === true || raw === 1 || raw === '1' || raw === 'true'; + } + + await knex('feature_flags').insert({ key: 'clients', value: initialValue }); +}; + +exports.down = async function(knex) { + if (!(await knex.schema.hasTable('feature_flags'))) return; + await knex('feature_flags').where({ key: 'clients' }).del(); +}; diff --git a/backend/migrations/core/098_add_email_template_category.js b/backend/migrations/core/098_add_email_template_category.js new file mode 100644 index 00000000..a3e44a0c --- /dev/null +++ b/backend/migrations/core/098_add_email_template_category.js @@ -0,0 +1,128 @@ +/** + * Migration: Categorise email templates + link them to feature flags. + * + * Adds three metadata columns to `email_templates`: + * + * - `category` — top-level display group in the admin Templates UI. + * One of: + * 'core' — gallery delivery, admin, system, backups. + * Always visible, no feature flag. + * 'customers' — customer-portal lifecycle (invitation, reset). + * 'billing' — Bills feature (#354, not yet built). + * 'quotes' — Quotes feature (#354, not yet built). + * 'calendar' — Calendar feature (#354, not yet built). + * Values outside this set are accepted (forward-compat) but the + * UI will lump them under 'core' for now. + * + * - `subcategory` — second-level group inside `core` (which is busy + * with 14 templates). One of: + * 'gallery' — gallery delivery lifecycle (created / expiring / + * expired / archived). + * 'admin' — admin lifecycle (invitation, password reset). + * 'backup' — DB + file backups (completed / failed) and + * restores. + * 'system' — version update notifications. + * Only meaningful when category='core'; other categories ignore + * it. NULL on rows that don't need a sub-bucket. + * + * - `feature_flag` — name of the feature flag whose `false` value + * should mark this template as "Feature off" in the admin UI. + * NULL means the template is always active (gallery delivery, + * admin lifecycle, system notifications). + * + * Categorisation does NOT hide templates. Disabled-feature templates + * stay visible and editable so admins can prep them before a feature + * launch; the UI shows a small "Feature off" chip on the entry. + * + * Idempotent: re-runs are no-ops. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + + const hasCategory = await knex.schema.hasColumn('email_templates', 'category'); + if (!hasCategory) { + await knex.schema.alterTable('email_templates', (table) => { + // Default 'core' so existing rows aren't NULL; the backfill below + // overrides for templates that belong to a feature group. + table.string('category', 32).notNullable().defaultTo('core'); + }); + } + + const hasSubcategory = await knex.schema.hasColumn('email_templates', 'subcategory'); + if (!hasSubcategory) { + await knex.schema.alterTable('email_templates', (table) => { + table.string('subcategory', 32).nullable(); + }); + } + + const hasFeatureFlag = await knex.schema.hasColumn('email_templates', 'feature_flag'); + if (!hasFeatureFlag) { + await knex.schema.alterTable('email_templates', (table) => { + table.string('feature_flag', 64).nullable(); + }); + } + + // Backfill — keyed by template_key so we don't accidentally update + // a row that's been renamed. Templates not in this map keep the + // 'core' / NULL defaults from the column definitions above. + 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 }, + // Customer portal (#354). Admin-triggered password reset for + // customer accounts ships in the same feature, so both templates + // share the `customers` category and the `customerPortal` flag. + // Future calendar / quotes / bills templates will land here under + // their own categories. + customer_invitation: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' }, + customer_password_reset: { category: 'customers', subcategory: null, feature_flag: 'customerPortal' }, + }; + + for (const [key, meta] of Object.entries(TEMPLATE_METADATA)) { + await knex('email_templates') + .where({ template_key: key }) + .update({ + category: meta.category, + subcategory: meta.subcategory, + feature_flag: meta.feature_flag, + }); + } +}; + +exports.down = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + + if (await knex.schema.hasColumn('email_templates', 'feature_flag')) { + await knex.schema.alterTable('email_templates', (table) => { + table.dropColumn('feature_flag'); + }); + } + + if (await knex.schema.hasColumn('email_templates', 'subcategory')) { + await knex.schema.alterTable('email_templates', (table) => { + table.dropColumn('subcategory'); + }); + } + + if (await knex.schema.hasColumn('email_templates', 'category')) { + await knex.schema.alterTable('email_templates', (table) => { + table.dropColumn('category'); + }); + } +}; diff --git a/backend/migrations/core/099_seed_missing_email_template_translations.js b/backend/migrations/core/099_seed_missing_email_template_translations.js new file mode 100644 index 00000000..38c93574 --- /dev/null +++ b/backend/migrations/core/099_seed_missing_email_template_translations.js @@ -0,0 +1,797 @@ +/** + * Migration: Auto-fill missing email-template translations for nl / pt / + * ru / fr — plus the en/de rows for templates that were seeded AFTER + * migration 075 ran (customer_password_reset from 092, version_update_test + * from 087). Those two carry their EN/DE content in the legacy + * subject_en/body_html_en/... columns; without a row in + * email_template_translations, the admin Templates UI shows them as + * empty until an admin clicks save. + * + * Coverage going in: + * - gallery_created / expiration_warning / gallery_expired / + * archive_complete already had en/de/nl/pt/ru from migration 075 + * → this migration adds the missing `fr` row. + * - admin_*, backup_*, restore_*, database_backup_*, customer_invitation, + * version_update_available had en/de only → this migration adds + * nl/pt/ru/fr. + * - customer_password_reset + version_update_test had legacy-column + * EN/DE only (post-075 inserts) → this migration adds the full + * en/de/nl/pt/ru/fr set, sourcing en/de from the legacy columns + * when present and falling back to the curated copy below. + * + * The non-EN/DE translations below were generated by an LLM and are + * flagged in the PR description as needing native-speaker review + * before the next stable release. en / de remain hand-translated. + * + * Idempotent: every insert checks (template_id, language) for an + * existing row first and skips if present. Safe to re-run. + * + * Variable placeholders ({{name}}) are preserved verbatim across all + * locales so emailProcessor's safeTemplateReplace continues to wire + * them up unchanged. + */ + +const TRANSLATIONS = { + // ──────────────────────────────────────────────────────────────── + // Gallery delivery (core) — only fr is missing, the rest landed in + // migration 075. + // ──────────────────────────────────────────────────────────────── + gallery_created: { + fr: { + subject: 'Votre galerie photo est prête !', + body_html: `

Galerie créée avec succès

+

Bonjour {{host_name}},

+

Votre galerie photo « {{event_name}} » a été créée avec succès !

+

Détails de la galerie :

+ +

Partagez ce lien et le mot de passe avec vos invités pour qu'ils puissent voir et télécharger les photos.

+{{#if welcome_message}}

{{welcome_message}}

{{/if}}`, + body_text: `Galerie créée avec succès\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a été créée avec succès !\n\nLien de la galerie : {{gallery_link}}\nMot de passe : {{gallery_password}}\nExpire le : {{expiry_date}}`, + }, + }, + + expiration_warning: { + fr: { + subject: 'Votre galerie photo expire bientôt', + body_html: `

La galerie expire bientôt

+

Bonjour {{host_name}},

+

Votre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.

+

Après l'expiration, la galerie sera archivée et ne sera plus accessible aux invités.

+

Voir la galerie

`, + body_text: `La galerie expire bientôt\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » expire dans {{days_remaining}} jours.\n\nGalerie : {{gallery_link}}`, + }, + }, + + gallery_expired: { + fr: { + subject: 'Galerie photo expirée et archivée', + body_html: `

Galerie archivée

+

Bonjour {{host_name}},

+

Votre galerie photo « {{event_name}} » a expiré et a été archivée.

+

Les invités ne peuvent plus accéder à la galerie. Contactez votre photographe si vous avez besoin de restaurer l'accès.

`, + body_text: `Galerie archivée\n\nBonjour {{host_name}},\n\nVotre galerie photo « {{event_name}} » a expiré et a été archivée.`, + }, + }, + + archive_complete: { + fr: { + subject: 'Galerie archivée : {{event_name}}', + body_html: `

Galerie archivée avec succès

+

La galerie photo « {{event_name}} » a été archivée.

+

Détails de l'archive :

+`, + body_text: `Galerie archivée avec succès\n\nLa galerie photo « {{event_name}} » a été archivée.\n\nTaille : {{archive_size}}\nPhotos : {{photo_count}}\nEmplacement : {{archive_path}}`, + }, + }, + + // ──────────────────────────────────────────────────────────────── + // Admin / RBAC — invitation + password reset. en/de exist, adding + // nl/pt/ru/fr. + // ──────────────────────────────────────────────────────────────── + admin_invitation: { + nl: { + subject: 'U bent uitgenodigd om deel te nemen aan PicPeak als {{role_name}}', + body_html: `

Welkom bij PicPeak

+

U bent uitgenodigd door {{inviter_name}} om deel te nemen aan PicPeak als {{role_name}}.

+

Klik op de onderstaande link om uw account in te stellen:

+

Account instellen

+

Deze uitnodiging verloopt op {{expires_at}}.

+

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}}.

+

Clique no link abaixo para configurar sua conta:

+

Configurar conta

+

Este convite expira em {{expires_at}}.

+

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 :

+

Configurer le compte

+

Cette invitation expire le {{expires_at}}.

+

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:

+

Nieuw wachtwoord instellen

+

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:

+

Definir 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 :

+

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.

+
+ Account instellen +
+

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.

+
+ Configurar conta +
+

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.

+
+ Configurer le compte +
+

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.

+

Set a new password

+

This link expires on {{expires_at}}.

+

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.

+

Neues Passwort festlegen

+

Dieser Link läuft am {{expires_at}} ab.

+

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.

+

Nieuw wachtwoord instellen

+

Deze link verloopt op {{expires_at}}.

+

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.

+

Definir nova senha

+

Este link expira em {{expires_at}}.

+

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: `

Здравствуйте!

+

Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.

+

Задать новый пароль

+

Срок действия ссылки истекает {{expires_at}}.

+

Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.

`, + 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.

+

Définir un nouveau mot de passe

+

Ce lien expire le {{expires_at}}.

+

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.

+`, + 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.

+`, + 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: `

Резервная копия базы данных создана

+

Запланированное резервное копирование базы данных успешно завершено.

+`, + 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.

+`, + 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.

+ +

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.

+ +

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: `

Ошибка резервного копирования базы данных

+

Запланированное резервное копирование базы данных завершилось с ошибкой и требует ручной проверки.

+ +

Проверьте журналы сервера для получения дополнительной информации.

`, + 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.

+ +

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.

+`, + 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.

+`, + body_text: `Restauração concluída\n\nHorário: {{completed_at}}\nOrigem: {{source_backup}}`, + }, + ru: { + subject: '[PicPeak] Восстановление БД успешно завершено', + body_html: `

Восстановление базы данных завершено

+

Ручная операция восстановления базы данных успешно завершена.

+`, + 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.

+`, + body_text: `Restauration terminée\n\nHeure : {{completed_at}}\nSource : {{source_backup}}`, + }, + }, + + restore_failed: { + nl: { + subject: '[PicPeak] Database-herstel MISLUKT', + body_html: `

Database-herstel mislukt

+

De handmatige database-herstel-operatie is mislukt en moet handmatig worden onderzocht.

+`, + body_text: `Database-herstel mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`, + }, + pt: { + subject: '[PicPeak] FALHA na restauração do banco de dados', + body_html: `

Falha na restauração

+

A restauração manual do banco de dados falhou e precisa de investigação.

+`, + body_text: `Falha na restauração\n\nHorário: {{failed_at}}\nErro: {{error_message}}`, + }, + ru: { + subject: '[PicPeak] ОШИБКА восстановления БД', + body_html: `

Ошибка восстановления базы данных

+

Ручная операция восстановления базы данных завершилась с ошибкой и требует проверки.

+`, + 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.

+`, + 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: `Bestandsback-up voltooid\n\nTijdstip: {{completed_at}}\nGrootte: {{backup_size}}\nLocatie: {{backup_path}}`, + }, + pt: { + subject: '[PicPeak] Backup de arquivos concluído', + body_html: `

Backup de arquivos concluído

+

O backup agendado de arquivos foi concluído com sucesso.

+`, + body_text: `Backup de arquivos concluído\n\nHorário: {{completed_at}}\nTamanho: {{backup_size}}\nLocalização: {{backup_path}}`, + }, + ru: { + subject: '[PicPeak] Резервное копирование файлов завершено', + body_html: `

Резервное копирование файлов завершено

+

Запланированное резервное копирование файлов успешно завершено.

+`, + body_text: `Резервное копирование файлов завершено\n\nВремя: {{completed_at}}\nРазмер: {{backup_size}}\nРасположение: {{backup_path}}`, + }, + fr: { + subject: '[PicPeak] Sauvegarde des fichiers terminée', + body_html: `

Sauvegarde des fichiers terminée

+

La sauvegarde planifiée des fichiers s'est terminée avec succès.

+`, + body_text: `Sauvegarde des fichiers terminée\n\nHeure : {{completed_at}}\nTaille : {{backup_size}}\nEmplacement : {{backup_path}}`, + }, + }, + + backup_failed: { + nl: { + subject: '[PicPeak] Bestandsback-up MISLUKT', + body_html: `

Bestandsback-up mislukt

+

De geplande bestandsback-up is mislukt en moet worden onderzocht.

+`, + body_text: `Bestandsback-up mislukt\n\nTijdstip: {{failed_at}}\nFoutmelding: {{error_message}}`, + }, + pt: { + subject: '[PicPeak] FALHA no backup de arquivos', + body_html: `

Falha no backup de arquivos

+

O backup agendado de arquivos falhou e precisa de investigação.

+`, + body_text: `Falha no backup de arquivos\n\nHorário: {{failed_at}}\nErro: {{error_message}}`, + }, + ru: { + subject: '[PicPeak] ОШИБКА резервного копирования файлов', + body_html: `

Ошибка резервного копирования файлов

+

Запланированное резервное копирование файлов завершилось с ошибкой и требует проверки.

+`, + body_text: `Ошибка резервного копирования файлов\n\nВремя: {{failed_at}}\nОшибка: {{error_message}}`, + }, + fr: { + subject: '[PicPeak] ÉCHEC de la sauvegarde des fichiers', + body_html: `

Échec de la sauvegarde des fichiers

+

La sauvegarde planifiée des fichiers a échoué et nécessite une investigation.

+`, + body_text: `Échec de la sauvegarde des fichiers\n\nHeure : {{failed_at}}\nErreur : {{error_message}}`, + }, + }, + + // ──────────────────────────────────────────────────────────────── + // Version update notifications + // ──────────────────────────────────────────────────────────────── + version_update_available: { + nl: { + subject: 'PicPeak-update beschikbaar: versie {{new_version}}', + body_html: `

Nieuwe PicPeak-versie beschikbaar

+

Er is een nieuwe versie van PicPeak beschikbaar.

+ +

Release-notities bekijken

`, + body_text: `Nieuwe PicPeak-versie beschikbaar\n\nHuidige versie: {{current_version}}\nNieuwe versie: {{new_version}}\nReleasekanaal: {{channel}}\n\nRelease-notities: {{release_url}}`, + }, + pt: { + subject: 'Atualização do PicPeak disponível: versão {{new_version}}', + body_html: `

Nova versão do PicPeak disponível

+

Uma nova versão do PicPeak está disponível.

+ +

Ver notas da versão

`, + body_text: `Nova versão do PicPeak disponível\n\nVersão atual: {{current_version}}\nNova versão: {{new_version}}\nCanal: {{channel}}\n\nNotas da versão: {{release_url}}`, + }, + ru: { + subject: 'Доступно обновление PicPeak: версия {{new_version}}', + body_html: `

Доступна новая версия PicPeak

+

Появилась новая версия PicPeak.

+ +

Посмотреть примечания к выпуску

`, + body_text: `Доступна новая версия PicPeak\n\nТекущая версия: {{current_version}}\nНовая версия: {{new_version}}\nКанал: {{channel}}\n\nПримечания к выпуску: {{release_url}}`, + }, + fr: { + subject: 'Mise à jour PicPeak disponible : version {{new_version}}', + body_html: `

Nouvelle version de PicPeak disponible

+

Une nouvelle version de PicPeak est disponible.

+ +

Voir les notes de version

`, + 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.

+

Set a new password

+

This link expires on {{expires_at}}.

+

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.

+

Neues Passwort festlegen

+

Dieser Link läuft am {{expires_at}} ab.

+

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.

+

Nieuw wachtwoord instellen

+

Deze link verloopt op {{expires_at}}.

+

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.

+

Definir nova senha

+

Este link expira em {{expires_at}}.

+

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: `

Здравствуйте!

+

Ваш фотограф инициировал сброс пароля для вашей клиентской учётной записи.

+

Задать новый пароль

+

Срок действия ссылки истекает {{expires_at}}.

+

Если вы не ожидали этого письма, можете его проигнорировать — ваш текущий пароль продолжит работать, пока вы не перейдёте по ссылке.

`, + 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.

+

Définir un nouveau mot de passe

+

Ce lien expire le {{expires_at}}.

+

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