diff --git a/README.md b/README.md
index 76aae2e6..441b6feb 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ docker compose up -d
# Access at http://localhost:3000
```
-On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](docs/_to-migrate/first-run-setup.md)**.
+On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker compose up -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
@@ -80,11 +80,11 @@ Unlike expensive SaaS solutions, PicPeak gives you:
## ✨ Features
-**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](docs/live-slideshow.md) projector view that auto-picks-up new uploads during live events.
+**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
**For clients** — clean mobile-optimized galleries, one-click bulk downloads, smart search, optional guest uploads, and download protection (watermarking + right-click prevention).
-**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](docs/_to-migrate/storage-backends.md), [webhooks](docs/_to-migrate/webhooks.md), and security-first defaults (JWT, rate limiting, CORS).
+**Technical** — Docker-ready, automatic thumbnail generation, external media reference mode, smart archiving of expired galleries, S3-compatible [storage backends](https://docs.picpeak.app/features/storage-backends), [webhooks](https://docs.picpeak.app/features/webhooks), and security-first defaults (JWT, rate limiting, CORS).
🧾 For studios — CRM & Accounting (Beta, off by default)
@@ -98,7 +98,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
> [!WARNING]
-> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[docs/crm-disclaimers.md](docs/crm-disclaimers.md)** first.
+> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
## 📖 Documentation
@@ -109,14 +109,14 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
| 🚀 Deployment (Docker, env, reverse proxy, SSL) | [docs.picpeak.app/deployment](https://docs.picpeak.app/deployment) |
| ⚙️ Admin settings reference | [docs.picpeak.app/guides/admin-settings](https://docs.picpeak.app/guides/admin-settings) |
| 🎯 Creating events | [docs.picpeak.app/guides/creating-events](https://docs.picpeak.app/guides/creating-events) |
-| 📽️ Live Slideshow | [docs/live-slideshow.md](docs/live-slideshow.md) |
-| 💾 Backup & Restore | [docs/backup-restore.md](docs/backup-restore.md) |
+| 📽️ Live Slideshow | [docs.picpeak.app/features/live-slideshow](https://docs.picpeak.app/features/live-slideshow) |
+| 💾 Backup & Restore | [docs.picpeak.app/guides/backup-restore](https://docs.picpeak.app/guides/backup-restore) |
| 🔌 API reference | [docs.picpeak.app/api](https://docs.picpeak.app/api) |
-| 🪝 Webhooks | [docs/_to-migrate/webhooks.md](docs/_to-migrate/webhooks.md) |
-| 💾 Storage backends (local / S3) | [docs/_to-migrate/storage-backends.md](docs/_to-migrate/storage-backends.md) |
-| 💻 System requirements & tuning | [docs/_to-migrate/system-requirements.md](docs/_to-migrate/system-requirements.md) |
-| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](docs/crm-disclaimers.md) |
-| 🗺️ Roadmap | [docs/_to-migrate/roadmap.md](docs/_to-migrate/roadmap.md) |
+| 🪝 Webhooks | [docs.picpeak.app/features/webhooks](https://docs.picpeak.app/features/webhooks) |
+| 💾 Storage backends (local / S3) | [docs.picpeak.app/features/storage-backends](https://docs.picpeak.app/features/storage-backends) |
+| 💻 System requirements & tuning | [docs.picpeak.app/deployment/system-requirements](https://docs.picpeak.app/deployment/system-requirements) |
+| 🧾 CRM & Accounting | [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm) · [disclaimers](https://docs.picpeak.app/features/crm/disclaimers) |
+| 🗺️ Roadmap | [GitHub Issues](https://github.com/PicPeak/picpeak/issues) |
**Project meta:** [Contributing](CONTRIBUTING.md) · [License](LICENSE) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
@@ -141,7 +141,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
-- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](docs/_to-migrate/storage-backends.md)
+- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 406aaedd..7ecd9ae3 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -68,7 +68,7 @@ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# PDFs to flat PNGs server-side so the admin UI NEVER renders a raw (possibly
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
-# documents (see docs/accounting-inbound-invoices.md).
+# documents (see https://docs.picpeak.app/features/accounting/incoming-invoices).
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
# thumbnails/displays that preview while keeping the original for download.
diff --git a/backend/server.js b/backend/server.js
index 1b41b3ec..e93f33d4 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -497,7 +497,7 @@ app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads
// are stable (e.g. Inter/400.woff2), so an admin replacing the file on disk
// must be able to roll out the change to clients. With max-age + Last-Modified
// (set by express.static from file mtime), browsers send If-Modified-Since
-// after expiry and pick up the new version automatically. See docs/fonts.md
+// after expiry and pick up the new version automatically. See https://docs.picpeak.app/guides/custom-fonts
// "Replacing an existing font" for the documented rollout strategy.
const fontStaticOpts = { maxAge: '7d' };
app.use(
diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js
index 9555db84..9e333407 100644
--- a/backend/src/routes/adminFeatureFlags.js
+++ b/backend/src/routes/adminFeatureFlags.js
@@ -63,7 +63,7 @@ const KNOWN_FLAGS = [
// are a standalone legal document type with their own composition
// (blocks) and signing flow (in-browser canvas + wet-signed PDF
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
- // lawyer review before sending. See docs/crm-disclaimers.md.
+ // lawyer review before sending. See https://docs.picpeak.app/features/crm/disclaimers.
'contracts',
// Accounting (migration 122). Top-level Accounting area — inbound
// supplier invoices, expenses + re-bill, and the tax report (which
diff --git a/docs/_to-migrate/README.md b/docs/_to-migrate/README.md
deleted file mode 100644
index d63301d4..00000000
--- a/docs/_to-migrate/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# `_to-migrate` — staging area, **not canonical**
-
-> [!WARNING]
-> These pages were cut out of the root `README.md` to slim it down. They are
-> **being ported to the PicPeak docs site** (its own repo → docs.picpeak.app).
->
-> - **Do not edit** these files expecting the change to be authoritative — edit
-> the docs-site copy once it exists.
-> - **Do not add new links** to this folder from code or other docs.
-> - This whole folder is **deleted** once the docs-site pages are live (see the
-> 3-phase plan in PicPeak/picpeak#1000).
-
-The root README links here only as a temporary bridge so no link 404s while the
-docs-site pages are being written.
-
-| File | Ported to docs.picpeak.app path |
-|---|---|
-| `webhooks.md` | `/features/webhooks` |
-| `storage-backends.md` | `/deployment` (storage section) |
-| `first-run-setup.md` | `/deployment` (first run / permissions) |
-| `system-requirements.md` | `/deployment` (system requirements) |
-| `roadmap.md` | project roadmap / GitHub Projects (TBD) |
diff --git a/docs/_to-migrate/first-run-setup.md b/docs/_to-migrate/first-run-setup.md
deleted file mode 100644
index 14fd38ac..00000000
--- a/docs/_to-migrate/first-run-setup.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# First run — create your admin account
-
-On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
-
-1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
-2. Read the **one-time setup token** from the 0600 file the backend writes it to
- (it is deliberately *not* printed to the logs — that would leave a live
- bootstrap credential in `docker logs`):
- ```bash
- docker compose exec backend cat /app/data/SETUP_TOKEN
- ```
- It is bind-mounted, so `sudo cat data/SETUP_TOKEN` on the host works too. Only
- if that file could not be written does the backend fall back to logging the
- token (`docker compose logs backend | grep -i "setup token"`).
-3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
-
-> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
-
-## Docker file permissions
-
-- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
-- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
-
-## ARM64 (aarch64) systems
-
-Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](../../docker-compose.amd64.override.yml) for a transitional fallback.
diff --git a/docs/_to-migrate/roadmap.md b/docs/_to-migrate/roadmap.md
deleted file mode 100644
index 6002839b..00000000
--- a/docs/_to-migrate/roadmap.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Roadmap
-
-We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
-
-## 🚧 Beta Features (Use at your own risk)
-
-These features are currently in beta testing and may have limited functionality or stability:
-
-| Feature | Description | Status |
-|---------|-------------|--------|
-| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
-| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
-
-## 📋 Future Enhancements
-
-| Feature | Description | Priority | Status |
-|---------|-------------|----------|---------|
-| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
-| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
-| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
-| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
-| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
-| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
-| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
-| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
-| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
-
-**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
diff --git a/docs/_to-migrate/storage-backends.md b/docs/_to-migrate/storage-backends.md
deleted file mode 100644
index d6afc191..00000000
--- a/docs/_to-migrate/storage-backends.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Storage Backends
-
-PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
-
-| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
-|---|---|---|
-| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
-| Admin UI upload | ✅ | ✅ |
-| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
-| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
-| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
-| Backups | ✅ | ✅ |
-| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
-
-## Switching to an S3-compatible backend
-
-1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
-2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
-3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
-4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
-
-Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
diff --git a/docs/_to-migrate/system-requirements.md b/docs/_to-migrate/system-requirements.md
deleted file mode 100644
index 33984cfa..00000000
--- a/docs/_to-migrate/system-requirements.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# System Requirements
-
-## Minimum Requirements
-- **CPU**: 2 CPU cores
-- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
- decodes the full uncompressed frame before resize, and the default two
- worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
- batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
- backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
- hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
-- **Storage**: 20GB minimum (plus photo storage needs)
-- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
-- **Node.js**: v18.0.0 or higher
-- **Database**: SQLite (included) or PostgreSQL 12+
-
-## Docker Requirements (Recommended)
-- **Docker**: v20.10.0+
-- **Docker Compose**: v2.0.0+
-
-## Low-memory hosts
-
-Running on 2 GB RAM (e.g. an entry-level VPS) is workable but requires
-tuning the upload-processor concurrency down. The backend auto-detects
-total RAM at startup via `os.totalmem()` — on a host that reports < 3 GB,
-it defaults `UPLOAD_PROCESSOR_CONCURRENCY` to **1** instead of 2 and logs
-a one-shot warning. You can pin the value explicitly in `.env`:
-
-```env
-# Single worker loop — slower batch processing, lower peak RSS
-UPLOAD_PROCESSOR_CONCURRENCY=1
-```
-
-The trade-off is throughput: a single worker processes one photo at a
-time, so a 100-photo batch takes ~2× as long but won't OOM. **Health-check
-note**: if the backend dies under memory pressure, the gallery serves
-`503 Service Unavailable` on thumbnails until Docker's
-`restart: unless-stopped` brings the container back. Persistent 503s
-during/after an upload batch on a low-memory host are almost always this.
-
-## Video Support Requirements
-When enabling video uploads, consider these additional resources:
-
-| Resource | Recommendation | Notes |
-|----------|----------------|-------|
-| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
-| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
-| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
-| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
-
-**Technical Notes:**
-- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
-- Maximum upload size: **10GB per video file**
-- Chunked upload support for files >100MB (resumable uploads)
-- Supported formats: MP4, WebM, MOV, AVI
-- Video thumbnails are automatically generated from the first few seconds
-
-**For Nginx/Reverse Proxy:**
-If using Nginx, increase the client max body size:
-```nginx
-client_max_body_size 10G;
-proxy_read_timeout 3600;
-proxy_send_timeout 3600;
-```
diff --git a/docs/_to-migrate/webhooks.md b/docs/_to-migrate/webhooks.md
deleted file mode 100644
index bb497018..00000000
--- a/docs/_to-migrate/webhooks.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Webhooks
-
-PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
-
-## Event types
-
-| Event | Fires when |
-|---|---|
-| `event.created` | Gallery created (admin or API) |
-| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
-| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
-| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
-| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
-| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
-
-## Payload shape
-
-```json
-{
- "id": "delivery-uuid",
- "type": "event.published",
- "created_at": "2026-04-28T05:25:00.000Z",
- "data": {
- "event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
- }
-}
-```
-
-Also sent on every request:
-- `X-PicPeak-Signature` — `HMAC-SHA256(secret, raw_body)` as hex
-- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
-- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
-- `User-Agent: PicPeak-Webhooks/1.0`
-
-## Verifying signatures
-
-**Node.js**
-```js
-const crypto = require('crypto');
-function verify(secret, rawBody, signature) {
- const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
- const a = Buffer.from(expected, 'hex');
- const b = Buffer.from(signature, 'hex');
- if (a.length !== b.length) return false;
- return crypto.timingSafeEqual(a, b);
-}
-```
-
-**Python**
-```python
-import hmac, hashlib
-def verify(secret: str, raw_body: bytes, signature: str) -> bool:
- expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
- return hmac.compare_digest(expected, signature)
-```
-
-**curl + openssl** (one-liner for a quick replay)
-```sh
-SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
-[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
-```
-
-## Retries + observability
-
-- `2xx` → success, recorded with latency
-- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
-- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
-- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
-- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
-
-The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
-
-## SSRF protection
-
-Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
-
-For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
diff --git a/docs/accounting-inbound-invoices.md b/docs/accounting-inbound-invoices.md
deleted file mode 100644
index 51a9d9a8..00000000
--- a/docs/accounting-inbound-invoices.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Accounting — Incoming invoices, expenses & re-bill
-
-> **Status:** built on `feat/accounting-inbound-invoices` (based on `upstream/beta`); not yet merged to `main`.
-> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER/ITSG. See `docs/crm-disclaimers.md`.
-
-## Why
-The studio receives supplier invoices/receipts (hotels, equipment, Fremdleistungen). This feature lets an admin **capture** an incoming invoice (upload, **phone/tablet camera**, or **IMAP email intake**), confirm its fields, give it a **disposition**, mark the **supplier payable** paid, and — for client-borne costs — **re-bill it to a client** ("Weiterverrechnung"), consolidated onto the client's bill the same way billable hours are.
-
-## Two distinct entities (split in migration 126)
-Incoming invoices and internal expenses are **separate** — one document never appears in both surfaces.
-
-- **Incoming invoices** (`inbound_documents`) — an *external* supplier document. The **row itself is the payable**: it carries the disposition, tax treatment, event booking, re-bill linkage, supplier-payment, note, and (for re-bills) the attached customer. Categorising it **updates the document** — it never derives an `expenses` row. Mark-paid lives here.
-- **Expenses** (`expenses`, `inbound_document_id IS NULL`) — *internal* own-costs: `kind = amount | mileage | per_diem` (amount = quantity × rate, rate from accounting settings with per-entry override), optional proof file, booked to an event or the company. Disposition is always `eigener_aufwand`; no supplier payment.
-
-This document covers the **incoming-invoices** surface. Expenses share the markup/re-bill helpers but are otherwise independent.
-
-## Lifecycle
-```
-capture (upload / camera / email)
- → inbox row, status = unsorted, parse_status = pending
-triage (confirm fields + disposition + note)
- ├─ eigener_aufwand → company expense (pick category), booked to company
- ├─ durchlaufend → pass-through; optionally attach a client (billed at cost)
- ├─ rebill → re-bill to a client (with markup)
- ├─ duplikat → status = duplicate (excluded from the books)
- └─ abgelehnt → status = declined (excluded from the books)
-supplier payment (independent axis): markInboundSupplierPayment → supplier_paid
-```
-
-### Dispositions
-Five: `rebill` · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt`.
-
-- **`rebill`** — your own supplier cost, invoiced on to a client, usually with a **markup** (percent or flat). Requires a customer.
-- **`durchlaufend`** — an amount fronted on behalf of a client and passed through **at cost / VAT-neutral**. May optionally attach a client (then it is re-billed like a rebill, but **never carries a markup** — enforced in both the UI and `categorizeInbound`). With no client it is only booked to an event/company.
-- **`eigener_aufwand`** — own cost, not re-billed; pick an expense category for the Erfolgsrechnung.
-
-The triage modal shows an **inline explainer** for the selected disposition (`accounting.disposition.help.*`) and a **note** field on every disposition.
-
-### Re-categorisation
-Categorising is **re-runnable** — a categorised invoice can be changed again (e.g. pass-through → company expense), including after the supplier has been paid (supplier-payment and classification are independent axes). When the document was already re-billed, `categorizeInbound` first **unwinds** the prior re-bill line (removes the invoice line, recomputes the invoice totals) before applying the new disposition. It **refuses** (`INVOICE_LOCKED`) only when the re-bill sits on an already-issued invoice — then a Storno is required (`isInvoiceMutable` mirrors the hour-entry lock rules). The only hard lock is an *issued* invoice, never supplier-payment.
-
-### Re-bill: cadence-aware, like hours
-Re-bill/pass-through-to-a-customer consolidates onto the client's bill exactly like `customerHoursService`:
-
-- **Monthly / manual customers** — the line is appended **immediately** onto the customer's running monthly draft (via `invoiceService.createInvoice`'s accumulator intercept). `billed_invoice_id` is set at categorise time.
-- **Per-event customers** — the item stays **PENDING** in the customer's pool (`customer_account_id` set, `billed_invoice_id` null). The inbox surfaces a **"Pending re-bills"** card grouped by customer; **"Bill these"** (`billPendingRebills`) bundles all of a customer's pending items into **one** invoice (one line per document), then navigates to the bill editor so the admin can add more lines before sending. This mirrors `billUnbilledEntries`.
-
-Markup resolution (rebill only): expense/document override → contract `Spesen-Zuschlag` clause → 0% (`resolveMarkup`). The re-bill line description is `"{supplier} (Weiterverrechnung)"` / `"… (Durchlaufende Position)"`.
-
-## Data model (migrations 122–132)
-All money is integer minor units (`*_amount_minor`). Additive, hasTable/hasColumn-guarded.
-
-- **122** — seed `accounting` master flag (default OFF; preserve-visuals auto-enable where `taxReport` was on).
-- **123** — `accounting.view` / `accounting.manage` permissions.
-- **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories).
-- **125** — contract `expense_markup_type|_percent|_flat_minor` (Spesen-Zuschlag clause).
-- **126** — split incoming vs expenses: disposition/tax_treatment/event_id/category_id, re-bill markup + `billed_invoice_id`/`billed_invoice_line_item_id`, supplier-payment columns on `inbound_documents`; `kind`/`quantity`/`rate_minor` on `expenses`.
-- **127** — separate `expenses` sub-flag + accounting `app_settings` (km/per-diem rate, require-proof). *(NB: `app_settings` has no `created_at/updated_at` — seed `setting_key/value/type` only.)*
-- **128** — incoming mail (IMAP): `incomingMail` flag + `email_configs.imap_*` + `received_emails`.
-- **129** — `ledger_accounts` + `vat_codes` (Swiss/LI KMU seed) + category→account mapping.
-- **130** — `vat_code` snapshot column on quotes + invoices.
-- **132** — `inbound_documents.note` + `inbound_documents.customer_account_id` (the attached re-bill client; loose link, indexed for the pending-pool lookup).
-
-`inbound_documents` key columns: parsed fields (`supplier_name`, `invoice_date`, `total/net/vat_amount_minor`, `iban`, `payment_reference`) + separate untrusted `qr_amount_minor` (tamper cross-check — the authoritative total is the text value); `status` (unsorted/categorized/declined/duplicate); `disposition`; `tax_treatment`; `event_id` (NULL = company); `category_id`; `customer_account_id`; `markup_type/_percent/_flat_minor`; `billed_invoice_id` + `_line_item_id`; `supplier_paid` + `_at/_method/_ref`; `note`.
-
-## API (`/api/admin/expenses`, gated by `incomingInvoices` + `accounting.*`)
-- `POST /inbound` (multipart) — capture (upload/camera). Deduped by SHA-256.
-- `GET /inbound` — list (joins the attached customer name/email).
-- `GET /inbound/pending-summary` — per-customer pending re-bills (registered before `/inbound/:id`).
-- `POST /inbound/bill-pending` — bundle one customer's pending re-bills into one invoice.
-- `GET /inbound/:id` · `PATCH /inbound/:id` (edit/confirm fields incl. `note`).
-- `GET /inbound/:id/page/:n` — rasterised PNG of a page. `GET /inbound/:id/file` — original (PDFs as attachment only, never inline).
-- `POST /inbound/:id/categorize` — set disposition (re-runnable; unwinds prior re-bill).
-- `POST /inbound/:id/rebill` — explicit "re-bill this one now" (forces an immediate single-doc bill).
-- `POST /inbound/:id/supplier-payment` — toggle supplier paid + method/date/reference.
-- Expenses: `GET/POST /`, `GET/PATCH /:id`, `POST /:id/invoice`, `POST /:id/paid`, `GET /:id/proof`.
-- Categories: `GET/POST/PATCH/DELETE /categories` (accounting master).
-
-## Document preview = server-side rasterised images
-Raw PDFs are **never** served inline. `rasterizeService` shells out to poppler `pdftoppm` (OS package in the Docker image — not a Node PDF lib, runs no JS, no egress). Pages cached under `storage/business-docs/inbound/rendered//page-.png`, served with `Content-Security-Policy: default-src 'none'` + `nosniff`. Page count capped at 200. The triage preview defaults to the last page (the Swiss QR-bill usually sits at the bottom).
-
-## Reporting & export
-- **Tax report** (`taxReportService`) — full Einnahmen-Ausgaben: incoming invoices + expenses feed the `costs` side, grouped Company vs Event; re-billed costs are kept (the matching re-bill revenue is also counted, so it nets). `vatPayable` = output VAT − reclaimable input VAT (excludes `foreign_vat_non_reclaimable`); zero when not VAT-registered. Gated on `accounting` + `taxReport` (no longer `bills`).
-- **Treuhänder export** (`ledgerService`) — accrual Buchungssätze → generic/Banana/bexio CSV. Accrual basis only; bank/payment postings are Layer B (deferred). See `project_banana_treuhaender_export_format`.
-- VAT config (codes, rate→code + treatment→code maps, registration & reclaim countries, chart of accounts) lives under **Settings → Accounting**; invoices snapshot the chosen `vat_code`.
-
-## Flag model
-`accounting` is an explicit top-level **master** flag with sub-toggles: `incomingInvoices` (this surface), `expenses` (internal expenses), `taxReport` (moved permanently out of CRM, now independent of `bills`). `incomingMail` (IMAP) is a separate flag, not under accounting. `accounting` off forces `taxReport` + `incomingInvoices` off.
-
-## Conventions followed
-Idempotent migrations; new flags default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates via `useLocalizedDate`; money via `utils/money`; every tax/legal surface carries a "verify with your Treuhänder" disclaimer.
-
-## Deferred
-- **OCR / auto-extract** — `extractionService` is a no-op stub (Tesseract + Swiss-QR decode); admin reads the slip and types the fields.
-- **Capture-time VAT reclaim default** — `accounting_vat_reclaim_countries` is stored but not yet consumed; needs a `supplier_country` column to default `tax_treatment`.
-- **Bank reconciliation** — match incoming payments to open invoices / confirm supplier invoices paid (LLB DataFeed / camt.053 / EBICS). Phased, Swiss/LI rails.
-- **Native double-entry (Layer B)** — picpeak stays a feeder/export tool below the CHF 500k threshold; full Erfolgsrechnung/Bilanz is out of scope.
diff --git a/docs/backup-restore.md b/docs/backup-restore.md
deleted file mode 100644
index f659f1b1..00000000
--- a/docs/backup-restore.md
+++ /dev/null
@@ -1,350 +0,0 @@
----
-title: Backup & Restore
-description: How picpeak captures your install, where backups land, and how to recover from them — including full disaster recovery.
-sidebar_position: 2
----
-
-# Backup & Restore
-
-picpeak's backup system captures your entire install — database, photos, CRM documents, gallery archives, and configuration — to a destination of your choice. Recovery happens through one of two paths depending on how badly things went wrong:
-
-- **The install is alive** → use the **Restore wizard** in the admin UI to roll back to a chosen backup.
-- **The install is gone** (host migration, `docker compose down -v`, drive replacement) → use the **install-from-backup** trigger file convention to rebuild in one boot, with no onboarding wizard and no temporary admin step.
-
-This guide covers both.
-
-## Table of contents
-
-- [What gets backed up](#what-gets-backed-up)
-- [Destinations](#destinations)
-- [Inline DB dump](#inline-db-dump)
-- [Custom backup paths](#custom-backup-paths)
-- [The Coverage tab](#the-coverage-tab)
-- [The Integrity tab](#the-integrity-tab)
-- [Restoring on a live install](#restoring-on-a-live-install)
-- [Disaster recovery (install from a backup)](#disaster-recovery-install-from-a-backup)
-- [Backup History detail](#backup-history-detail)
-- [Settings reference](#settings-reference)
-- [Troubleshooting](#troubleshooting)
-
-## What gets backed up
-
-Every "Run Backup Now" (manual or scheduled) produces:
-
-1. **A database dump** captured inline at the start of the run. Always included by default. picpeak refuses to ship a backup without a database dump unless the operator has explicitly opted out via the `backup_database_inline_dump` setting — see [Inline DB dump](#inline-db-dump) below.
-
-2. **Files from a configurable list of paths**, declared in the `backup_paths` table:
- | Path | Default | Notes |
- | --- | --- | --- |
- | `events/active` | ✓ | Live gallery photo originals |
- | `events/archived` | gated by `backup_include_archived` | Long-term archive |
- | `thumbnails` | ✓ | Generated thumbnails |
- | `previews` | ✓ | Lightbox preview tier |
- | `heroes` | ✓ | Gallery hero images |
- | `uploads` | ✓ | Wet-signature contracts, imported invoices, etc. |
- | `business-docs` | ✓ | CRM PDFs, signature artefacts, imported historical invoices |
-
- Admins can add or remove rows from `backup_paths` to teach the walker about new feature directories — see [Custom backup paths](#custom-backup-paths).
-
-3. **A manifest JSON** describing the run, written to `/manifests/backup-manifest-.json`. The manifest carries the database dump path, the file inventory, checksums, and per-path counters.
-
-## Destinations
-
-picpeak supports three destination types, configured via **Backup → Configuration**:
-
-- **Local** — files copied to a directory on the same host (default: `/backup` inside the container, which is typically a bind mount).
-- **S3 / MinIO** — files uploaded via the S3 API. Supports custom endpoints (for MinIO, Backblaze B2, Wasabi, etc.).
-- **rsync** — synchronised to a remote host over SSH.
-
-Direct download from the admin UI is supported for local destinations; S3 backups can be retrieved via pre-signed URLs.
-
-## Inline DB dump
-
-Every "Run Backup Now" runs `pg_dump` (or `sqlite3 .backup`) inline before walking files. This guarantees the manifest's `database.backup_file` is always a fresh capture, never a stale reference to a previously-scheduled dump that may not exist.
-
-If the inline dump fails (disk full, pg_dump crash, permission error), the run aborts and writes the error to `backup_runs.error_message`. The UI surfaces this as a failed run — no more silent files-only manifests.
-
-**To opt out** (e.g. if you have a separately-orchestrated DB backup that you trust more):
-
-```sql
-INSERT INTO app_settings (setting_key, setting_value, setting_type, updated_at)
-VALUES ('backup_database_inline_dump', 'false', 'backup', NOW())
-ON CONFLICT (setting_key) DO UPDATE
- SET setting_value = 'false', updated_at = NOW();
-```
-
-With inline-dump opted out, picpeak's fail-loud guard still applies: a file backup with no recent DB dump on file (within 26 hours) will fail rather than ship a files-only manifest.
-
-## Custom backup paths
-
-To add a new directory to the backup walker (e.g. you've shipped a feature that drops artefacts under `storage/my-feature/`):
-
-```sql
-INSERT INTO backup_paths (path, include_in_default, display_order, description, created_at, updated_at)
-VALUES ('my-feature', true, 100, 'My new feature artefacts', NOW(), NOW());
-```
-
-Next "Run Backup Now" picks it up — no restart, no migration. Set `include_in_default = false` to temporarily disable a path without dropping the row.
-
-The `feature_flag` column gates a path behind an app_settings boolean (matches how `events/archived` is gated by `backup_include_archived`). Useful when a backup path corresponds to an optional feature.
-
-## The Coverage tab
-
-**Backup → Coverage** answers "what will the next backup actually include?" without having to run it:
-
-- **Database** — inline-dump mode + last dump timestamp + staleness check
-- **Configured paths** — one row per `backup_paths` entry with its current coverage status (`will-scan` / `skipped-by-toggle` / `skipped-by-feature-flag` / `missing-on-disk`)
-- **Drift detection** — flags top-level directories under `STORAGE_PATH` that exist on disk but have NO matching `backup_paths` row. This is the canary for "a feature shipped without a matching backup row" — the most common cause of silent data loss in pre-2026-05 picpeak.
-
-The Coverage tab auto-fetches on open. If everything is green, your next backup will capture what you'd expect.
-
-## The Integrity tab
-
-**Backup → Integrity** verifies that every `*_path` column on quotes / contracts / invoices / signatures actually resolves to a file on disk, AND that files with a stored `*_sha256` still hash to the same value. Read-only, on-demand. Useful for:
-
-- Post-restore validation
-- Detecting bit-rot
-- Auditing legal-evidence artefacts before a tax review or dispute
-
-## Restoring on a live install
-
-Use this when picpeak is running and you want to roll back to a specific backup point — e.g. recovering accidentally-deleted records, reverting a bad migration, or testing a restore drill.
-
-**Backup → Restore** walks you through:
-
-1. **Source** — Local, S3, or rsync
-2. **Choose Backup** — manifests discovered from disk (works after a fresh install where `backup_runs` is empty) or from the database history
-3. **Restore Options** — Full / Database only / Files only / Selective + Force + Skip Pre-Restore
-4. **Review** — surfaces validation warnings before you commit
-5. **Restore Progress** — real-time stream of the actual steps
-
-Failures during restore trigger an automatic rollback from the pre-restore safety snapshot. The destination ends up either as the restored state OR as the original pre-restore state — never as a half-clobbered mix.
-
-### Restoring an older backup on a newer image
-
-picpeak's restore path is forward-compatible: a backup taken on an older version restores cleanly onto a newer image without any manual schema work. After loading the dump, the restore service runs the same `npm run migrate:safe` script that `wait-for-db.sh` uses on every container boot. Any migrations that have been added between the backup's snapshot and the current image are applied inline, against the freshly-restored DB, before the restore is reported as complete.
-
-Net effect: even if `bugfix/cool-new-feature` shipped a migration that adds a `widgets` table and your backup predates that branch, after restore your install has the `widgets` table (empty), the right indexes, and any seed rows the migration emits. No "you'll need to restart the container once" footnote.
-
-The same applies to the install-from-backup trigger — migrations land inside the restore boundary, so the moment the server prints `Server running on port 3000`, the schema matches the running image. Log in and use the install immediately.
-
-## Disaster recovery (install from a backup)
-
-For full DR after `docker compose down -v`, host migration, drive replacement, or moving an install between hosts. picpeak detects a trigger file on first boot and runs the restore before the admin UI surfaces. You open the browser, log in with your original credentials, and the install is fully populated.
-
-### Prerequisites
-
-- Your install's backup files must already be present in the `/backup` mount. They survive `docker compose down -v` because `/backup` is a bind mount, not a Docker-managed volume.
-- The image must include the install-from-backup feature (shipped 2026-05-31 on `beta`; available in `main` after the next stable release).
-- The backup must contain a database dump. The wizard cannot reconstruct your CRM data, customers, quotes, invoices, or admin users from a files-only backup. Confirm by inspecting any `backup-manifest-*.json` and checking that `database.backup_file` is non-null.
-
-### How the trigger works
-
-On every container start, picpeak's boot sequence checks for a trigger file in the root of the `/backup` mount. If found AND the destination database is empty, the restore runs automatically. After a successful restore the trigger file is deleted so the next boot doesn't redo the work. On failure the trigger file is preserved — fix the input and restart the container to retry.
-
-The trigger file is named **`RESTORE_ON_INSTALL`** (no extension) or **`RESTORE_ON_INSTALL.txt`** — either is accepted.
-
-### Two trigger flavors
-
-#### Auto-pick the newest backup
-
-Create an empty trigger file:
-
-```sh
-touch /path/to/backup/RESTORE_ON_INSTALL
-```
-
-The boot hook will scan `/backup/manifests/` for files matching `backup-manifest-*.json` (or `.yaml`) and pick the one with the most recent modification time. Best for the common DR case where you simply want the latest snapshot.
-
-#### Use a specific backup
-
-Write the path of the manifest you want — either relative to the `/backup` mount root or an absolute path — into the trigger file:
-
-```sh
-# Relative path (recommended)
-echo "manifests/backup-manifest-backup-20260530-190617-e9be97b3.json" \
- > /path/to/backup/RESTORE_ON_INSTALL
-
-# Or absolute path inside the container
-echo "/backup/manifests/backup-manifest-backup-20260530-190617-e9be97b3.json" \
- > /path/to/backup/RESTORE_ON_INSTALL
-```
-
-Use this when you need to restore an older backup (e.g. rolling back a data corruption that happened after the most recent backup ran).
-
-### Full DR walkthrough
-
-```sh
-# 1. Snapshot the backup outside the compose directory (belt + suspenders).
-# This is a docker-compose-down-v-proof copy in case anything goes wrong.
-SNAPSHOT=~/picpeak-snapshots/$(date +%Y%m%d-%H%M%S)
-mkdir -p "$SNAPSHOT" && cp -av /path/to/picpeak/backup/. "$SNAPSHOT/"
-
-# 2. Verify the backup is restorable. database.backup_file must be non-null.
-LATEST=$(ls -t /path/to/picpeak/backup/manifests/*.json | head -1)
-docker compose exec backend cat "/backup/manifests/$(basename $LATEST)" \
- | python3 -c "import json,sys; m=json.load(sys.stdin); \
- print('DB included:', bool(m.get('database',{}).get('backup_file')))"
-
-# 3. Drop the trigger file. Two variants — pick one:
-
-# (a) auto-pick newest
-touch /path/to/picpeak/backup/RESTORE_ON_INSTALL
-
-# (b) specific manifest
-echo "manifests/backup-manifest-backup-20260530-190617-e9be97b3.json" \
- > /path/to/picpeak/backup/RESTORE_ON_INSTALL
-
-# 4. Boot.
-docker compose down -v
-docker compose up -d
-docker compose logs -f backend --tail=100
-```
-
-When the boot log shows `Install-from-backup: restore completed successfully` followed by `Server running on port 3000`, the install is ready. Open the admin UI and log in with your original (pre-disaster) credentials.
-
-### Safety gates
-
-Three layers prevent accidental data loss:
-
-1. **The trigger file must exist.** No auto-magic — an admin explicitly drops the file to signal intent.
-
-2. **The destination database must be empty.** If the database contains any events, the install-from-backup hook refuses to run. The fresh-install default admin (auto-created by migration 001) is treated as throwaway and replaced by the backup's admin row, so a single admin user does not block the restore.
-
-3. **Failed restores roll back to the pre-restore state.** If anything fails after the DROP DATABASE step, picpeak's automatic rollback restores the destination from the pre-restore safety backup it took before starting.
-
-#### Override for advanced cases
-
-If you have a populated install you intentionally want to clobber (dev rebuilds, staging refresh, etc.):
-
-```yaml
-# In docker-compose.yml
-backend:
- environment:
- - INSTALL_FROM_BACKUP_FORCE=true
-```
-
-Or via the CLI:
-
-```sh
-INSTALL_FROM_BACKUP_FORCE=true docker compose up -d backend
-```
-
-With this set, gate #2 is skipped and the restore proceeds even with existing data. Gates #1 (trigger file presence) and #3 (rollback on failure) still apply.
-
-### Verifying DR success
-
-After the boot log shows `Install-from-backup: restore completed successfully`:
-
-```sh
-# Trigger should be gone (deleted on successful restore)
-ls /path/to/picpeak/backup/RESTORE_ON_INSTALL 2>/dev/null \
- || echo "Trigger cleaned up — restore succeeded."
-
-# Inspect the restore_runs row
-docker compose exec -T postgres psql -U picpeak -d picpeak_prod -c \
- "SELECT id, status, was_successful, was_rollback_attempted FROM restore_runs ORDER BY id DESC LIMIT 1;"
-
-# Confirm data is back
-docker compose exec -T postgres psql -U picpeak -d picpeak_prod -c "
- SELECT 'admin' AS t, COUNT(*) FROM admin_users
- UNION ALL SELECT 'events', COUNT(*) FROM events
- UNION ALL SELECT 'invoices', COUNT(*) FROM invoices
- UNION ALL SELECT 'app_settings', COUNT(*) FROM app_settings;"
-```
-
-Then open the admin login and use your **original** pre-disaster credentials.
-
-## Backup History detail
-
-Each row in **Backup → Backup History** expands to show:
-
-- **Database** — whether the dump was included
-- **Per-path file counts** — one row per `backup_paths` entry that contributed files, with count + total size. e.g.:
- ```
- events/active 142 (3.2 GB)
- business-docs 17 (4.5 MB)
- thumbnails 142 (12.4 MB)
- ```
-- **Total files** + total bytes
-- Error message if the run failed
-
-This breakdown reflects Stage B's data-driven walker, so admins can see at a glance which paths contributed how much.
-
-## Settings reference
-
-Backup-related settings live in `app_settings` with `setting_type = 'backup'` or `setting_type = 'restore'`:
-
-| Setting | Default | Notes |
-| --- | --- | --- |
-| `backup_enabled` | true | Master scheduler switch |
-| `backup_destination_type` | `'local'` | `'local'`, `'s3'`, or `'rsync'` |
-| `backup_destination_path` | `'/backup'` | Local destination |
-| `backup_database_inline_dump` | true | Inline DB dump on every run |
-| `backup_include_archived` | false | Gate `events/archived` |
-| `backup_incremental` | true | Skip unchanged files (checksum-tracked) |
-| `restore_allow_force` | true | Permit Force Restore via the wizard |
-| `restore_require_pre_backup` | true | Take a pre-restore safety snapshot |
-| `restore_verify_checksums` | true | Verify file checksums after restore |
-| `restore_email_on_completion` | true | Notify admin when restore finishes |
-| `restore_retention_days` | 30 | How long pre-restore snapshots survive |
-
-Most settings are exposed in the **Backup → Configuration** tab. Less common ones can be set via SQL.
-
-## Troubleshooting
-
-### "Run Backup Now" fails with `No database backup available`
-
-The inline DB dump was disabled AND no recent scheduled dump exists. Either re-enable inline dumps (set `backup_database_inline_dump = 'true'`) or configure a scheduled DB dump that completes within the staleness window (default 26h).
-
-### Backup History row says "completed" but shows 0 files
-
-This is the legacy of a pre-2026-05 install where the walker was hard-coded and missed paths. After upgrading, the new walker captures everything per `backup_paths`. The 0-file row is historical — new backups will count correctly.
-
-### Restore wizard shows "No backups found"
-
-The wizard's disk discovery looks in `backup_destination_path` + its `manifests/` subdirectory. If you moved manifests elsewhere or your bind mount points at a different host directory than expected, the discovery won't find them. Check `backup_destination_path` in the Configuration tab matches reality.
-
-### Restore completes but login fails
-
-Caused by the pre-2026-05 dead-pool bug — fixed in the current image. If you're on a stale image and still see this, restart the backend container once:
-
-```sh
-docker compose restart backend
-```
-
-Then try logging in again.
-
-### Install-from-backup: boot log shows no `Install-from-backup:` lines
-
-Check that the trigger file is actually visible from inside the container:
-
-```sh
-docker compose exec backend ls -la /backup/RESTORE_ON_INSTALL
-docker compose exec backend cat /backup/RESTORE_ON_INSTALL
-```
-
-If `ls` reports the file but the hook didn't run, the most likely cause is that the trigger pointed at a manifest that doesn't exist. The hook silently returns when the manifest path can't be resolved. Verify the path inside the file matches an actual manifest:
-
-```sh
-docker compose exec backend ls -la /backup/manifests/
-```
-
-### Install-from-backup: restore fails and leaves the trigger file in place
-
-This is the intentional behavior — fix the input, then restart the container to retry. The boot log will surface the underlying error (e.g. corrupt manifest, missing database dump file, validator warnings without the force override).
-
-Common failure modes:
-
-- **Backup is files-only** (`database.backup_file = null` in the manifest). Pick a different backup or proceed with caution via the admin wizard, knowing you will restore files only.
-- **Manifest path mismatch.** The path in the trigger file points at a manifest that doesn't exist. Either fix the path or use the empty-file auto-pick variant.
-- **Existing data** without the force override. Either start from a truly empty install (`docker compose down -v`) or set `INSTALL_FROM_BACKUP_FORCE=true`.
-
-### How do I disable install-from-backup entirely?
-
-Don't create a `RESTORE_ON_INSTALL` file. Without the trigger, the hook is a no-op on every boot. There is no separate "off switch" because the feature is opt-in by design.
-
-## See also
-
-- [Deployment](/deployment) — Docker, environment variables, volumes
-- [Admin Settings](/guides/admin-settings) — Configuration tab walkthrough
diff --git a/docs/crm-disclaimers.md b/docs/crm-disclaimers.md
deleted file mode 100644
index eb953e59..00000000
--- a/docs/crm-disclaimers.md
+++ /dev/null
@@ -1,164 +0,0 @@
-# CRM disclaimers — important reading for every picpeak operator
-
-The CRM module ships defaults that touch two regulated areas: **legally
-binding contracts** and **payment instruments (QR-bills, IBAN/BIC)**.
-The text and data picpeak renders are mechanically correct, but the
-**substance is the operator's responsibility**.
-
-> [!IMPORTANT]
-> Whatever picpeak ships in these two areas is an **EXAMPLE ONLY**. It is
-> every operator's own duty to have the content reviewed by their lawyer
-> (for contracts) and verified with their bank (for QR-bills / SEPA EPC
-> payloads) before sending it to a customer. Picpeak does not provide
-> legal advice and cannot validate banking data — only the operator can.
-
-## 1. Contract block library
-
-The contract feature (`feat/crm`, migration `130_add_contracts.js`) seeds
-twelve "system" blocks across six sections:
-
-- Basics — contract subject / scope-of-work header
-- Scope — image-rights clauses (private + commercial variants)
-- Privacy — model-release clauses (private / commercial / minors), DSGVO notice
-- Commercial — payment-terms reference, tiered cancellation schedule
-- NDA — mutual confidentiality
-- Closing — jurisdiction (CH + DE variants)
-
-All bodies are hand-written by the picpeak maintainer (DE first, EN
-translated). **None of them have been reviewed by a lawyer.** They are
-intended as starting points — every operator must:
-
-1. Read each system block they intend to send.
-2. Adjust the body text to match their own jurisdiction, business
- structure, and risk profile, in consultation with their lawyer.
-3. Where appropriate, replace a system block entirely with admin-authored
- blocks under their lawyer's guidance.
-
-The admin UI surfaces this disclaimer:
-- as a persistent banner on the Block Library page,
-- as a persistent banner on the Contract Editor,
-- as a "system block" badge plus an "Examples only — have your lawyer
- review" line on every seeded block's description.
-
-System blocks **cannot be deleted** (the seed migration would re-create
-them on re-run); operators who reject a seeded block toggle
-`is_active=false` on it so it stops appearing in new contracts. The body
-text of a system block is fully editable — when an operator's lawyer
-delivers a reviewed version, the operator pastes it into the system
-block and the new body is what gets snapshotted onto every subsequent
-contract.
-
-## 2. QR-bill / SEPA EPC payment payloads
-
-Picpeak is an open-source project. The invoice feature renders Swiss
-QR-bills and SEPA EPC QR codes from the data you typed (IBAN, BIC,
-account holder, amount, reference) — that's it. We don't have a way to
-tell whether the code actually scans correctly in your bank's app, so
-**please test that yourself before sending real invoices**.
-
-Before going live:
-
-1. Print one test invoice with the QR code.
-2. Scan it with the e-banking app of your own bank.
-3. If you expect customers on other banks (UBS, PostFinance, Raiffeisen,
- Migros Bank for Swiss QR; any major SEPA bank for EPC QR), scan with
- those too.
-4. If it doesn't scan or the prefilled fields look wrong, fix your
- bank-account data in picpeak and try again.
-
-**We are not responsible for any mistakes** in the rendered QR codes,
-payment data, or anything that flows from sending an invoice with bad
-data on it. That's why picpeak is MIT-licensed — use it freely, but
-the verification is on you.
-
-The admin UI surfaces this same note as a banner on the Business
-Profile → Bank Accounts and QR-format settings pages.
-
-## 3. Signature type — picpeak provides SES, not QES
-
-The contract signing flow (typed name + acceptance checkbox + canvas
-signature image + IP address + timestamp + SHA-256 audit page) is a
-**Simple Electronic Signature (SES)** under the EU eIDAS regulation
-and the Swiss ZertES. SES is the same legal tier as DocuSign's basic
-plan, HelloSign's free tier, or Adobe Acrobat Sign without a
-qualified-certificate add-on.
-
-### What SES is legally sufficient for
-
-In DACH (CH, DE, AT, FL), SES is valid and routinely upheld in civil
-court for contracts that **don't** legally require a specific form:
-
-- Photography service agreements
-- Image-rights / model-release clauses
-- Cancellation policies
-- NDAs between private parties
-- Most commercial service contracts
-- Most B2B agreements
-
-For these, picpeak's evidence chain (frozen block bodies + signature
-images + names + IPs + timestamps + content hashes + immutable
-audit-log timeline) is comparable to what an SES provider charging
-€10–30/month delivers. The audit page appended to the signed PDF
-makes the evidence self-contained — the customer can re-hash their
-copy and prove integrity without trusting picpeak's database.
-
-### What SES is NOT sufficient for
-
-Certain documents **legally require Schriftform** (handwritten
-signature on paper) OR a **Qualified Electronic Signature (QES)**
-backed by a certificate from an accredited Trust Service Provider
-(Swisscom Sign, D-Trust, A-Trust, Bundesdruckerei, etc.). The most
-common categories in DACH:
-
-| Jurisdiction | Document type | Statute |
-|---|---|---|
-| DE | Bürgschaft (guaranty) | § 766 BGB |
-| DE | Verbraucherdarlehensvertrag (consumer loan) | § 492 BGB |
-| DE | Befristete Arbeitsverträge (fixed-term employment) | § 14 Abs. 4 TzBfG |
-| DE | Kündigung Arbeitsverhältnis (employment termination) | § 623 BGB |
-| DE | Aufhebungsvertrag (employment cancellation agreement) | § 623 BGB |
-| CH | Bürgschaft above CHF 2'000 | Art. 493 OR |
-| CH | Eheverträge (matrimonial property agreements) | Art. 184 ZGB |
-| AT | Bürgschaftserklärung (guaranty declaration) | § 1346 ABGB |
-
-If you send any of these via picpeak's signing flow, the signature
-is **legally invalid** and the contract may be unenforceable. Use a
-QES provider for these documents.
-
-If you're unsure which category your contract falls into, ask your
-lawyer. The cost of asking is hours; the cost of getting it wrong
-is years.
-
-### What picpeak does NOT provide
-
-- **Identity verification.** Anyone who receives the signing email
- can sign. There's no second factor (SMS, video ident, ID upload).
-- **Qualified-certificate-based signatures (QES).** Requires a
- separate service.
-- **External / third-party timestamp.** All timestamps are
- server-side; an RFC 3161 Trust Service Provider timestamp would
- close the clock-manipulation defence but isn't currently part of
- the audit page.
-- **WORM / immutable storage.** Signed PDFs live on the regular
- filesystem path under `storage/business-docs/contract//`.
- Hardening this for high-stakes contracts is an infrastructure-
- level decision (S3 Object Lock, etc.) outside picpeak's code.
-
-The signing flow is fine for routine photographer-customer
-contracts. For anything with significant economic value or
-Schriftform-bound documents, layer a QES provider on top of
-picpeak's contract management.
-
-## Why this matters
-
-- **Liability.** Sending an unreviewed contract or a malformed QR-bill
- is the operator's liability — not picpeak's. The MIT licence
- explicitly disclaims warranty.
-- **Jurisdictional variance.** Even the most carefully drafted clause
- is wrong somewhere. The CH-jurisdiction closing block won't help a
- photographer in Bavaria. The cancellation schedule that's standard
- in Zurich would be challenged in Berlin.
-
-If you are unsure: don't send. Pause, read this file again, and run
-your seeded contract content past your lawyer (or scan one test QR-bill
-yourself) before turning the feature on for live customers.
diff --git a/docs/fonts.md b/docs/fonts.md
deleted file mode 100644
index 2bfca412..00000000
--- a/docs/fonts.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# Self-hosted webfonts
-
-PicPeak ships with a curated set of webfonts baked into the backend image and serves them from your own origin. **No requests go to `fonts.googleapis.com` or any third-party CDN** — guest IPs stay private, which is important for GDPR compliance (LG München 2022).
-
-The font picker in the admin theme customizer is **data-driven**: whatever the backend finds on disk, the picker offers. This page documents the conventions and the workflow for adding your own families.
-
-## What ships out of the box
-
-The Docker image bundles 8 OFL-licensed families at `backend/assets/fonts/`:
-
-- Comic Neue
-- IBM Plex Sans
-- Inter (the default)
-- Jost
-- Montserrat
-- Noto Sans
-- Playfair Display
-- Poppins
-
-These appear in the admin theme customizer with no configuration.
-
-## Adding your own font (drop a folder, restart)
-
-You don't need to fork the repo. Place a font folder in your runtime storage volume — the same volume that holds events, thumbnails, etc. — and it appears in the picker after the next backend restart (or within ~30 seconds of being added, whichever comes first).
-
-### 1. Choose where on the host
-
-Bind-mount target inside the container is `/app/storage/fonts/` (the env var `STORAGE_PATH` controls the prefix; defaults to `/app/storage`). On the host, that's wherever your `docker-compose.yml` mounts `${APP_STORAGE}` from — typically `./storage/`.
-
-### 2. Folder layout
-
-```
-storage/fonts/
-└── /
- ├── 400.woff2
- ├── 600.woff2
- ├── 700.woff2
- └── meta.json (optional)
-```
-
-Rules:
-
-- **Folder name** = display family name with spaces replaced by hyphens. The scanner turns `Roboto-Slab/` → `Roboto Slab`. Use the exact upstream family name; capitalisation is preserved.
-- **File names** are `.woff2` where `` is an integer (100-900). Other names are ignored. The picker doesn't expose individual weights, but the runtime injects all available weights in the `@font-face` block so headings (semibold/bold) render correctly.
-- **Format** must be `.woff2`. Other formats are ignored. WOFF2 is universally supported and the smallest on the wire.
-- **No italics** in v1 (the picker doesn't expose them). Italic files in the folder are silently ignored.
-- **`meta.json`** (optional) tells the picker which CSS generic family to fall back to while the font file is loading (and permanently if the file ever 404s). Shape: `{ "generic": "sans-serif" | "serif" | "cursive" | "monospace" }`. Defaults to `sans-serif` if absent. Add this for serif fonts (e.g. Playfair Display) and cursive/display fonts (e.g. Comic Neue, Lobster) so visitors don't briefly see Helvetica during the font fetch.
-
-### 3. Where to download fonts
-
-For Google-Fonts-licensed families, use [google-webfonts-helper](https://gwfh.mranftl.com/fonts):
-
-1. Pick the family.
-2. Charsets section → **Latin** only (uncheck others unless you actually need them; Cyrillic alone roughly doubles file size).
-3. Styles section → **400, 600, 700** at minimum (these match what the picker uses).
-4. Click "Download files" — you'll get a ZIP containing the `.woff2` files plus the family's OFL license.
-5. Rename the files to `400.woff2`, `600.woff2`, `700.woff2` and drop them in `storage/fonts//`.
-6. Keep the OFL license file alongside (the static handler serves anything in the folder, so `/fonts//OFL.txt` is publicly available — this satisfies OFL §2's "license must be included with all copies").
-
-For non-Google fonts, ensure you have the right to redistribute. SIL Open Font License (OFL), Apache 2.0, and most "free for commercial use" web licenses allow this.
-
-### 4. Activation
-
-Either:
-
-- **Restart the backend container** (immediate), or
-- **Wait ~30 seconds** for the in-memory cache to expire and the next `/api/public/fonts` request to re-scan.
-
-Refresh the admin customizer; the new family appears in the body and heading dropdowns.
-
-> **Note:** The admin customizer caches the fonts list separately for 5 minutes (React Query staleTime). After the backend picks up a new family, hard-reload the customizer page (⌘+Shift+R / Ctrl+Shift+R) to see it immediately, or wait up to 5 minutes for the frontend cache to expire on its own. The two caches serve different purposes — the backend avoids disk hits per request; the frontend avoids network hits per re-render — so we keep them independent and document the worst case rather than try to synchronise them.
-
-## How it works
-
-- **Scanner**: `backend/src/services/fontsService.js` reads two locations and merges them: `backend/assets/fonts/` (bundled) + `STORAGE_PATH/fonts/` (user). User additions override bundled families of the same name. Cached for 30 s.
-- **Listing endpoint**: `GET /api/public/fonts` returns `{ fonts: [{ family, weights, generic }, ...] }`.
-- **Static serving**: `GET /fonts//.woff2` returns the actual file. Path-traversal protected. `Cache-Control: max-age=7d` — clients revalidate via `If-Modified-Since` after expiry, so replacing a file on disk eventually rolls out without admin action (see "Replacing an existing font" below).
-- **Lazy injection**: `frontend/src/contexts/ThemeContext.tsx` watches `theme.fontFamily` / `theme.headingFontFamily` and injects exactly one `@font-face` block per family the page actually uses, into a single `