screenshots for admin MFA PR (#738)

This commit is contained in:
Paul Nothaft
2026-07-03 11:45:07 +02:00
commit 69fee5faba
1103 changed files with 290933 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
# 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 122132)
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/<id>/page-<n>.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.
+350
View File
@@ -0,0 +1,350 @@
---
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 `<destination>/manifests/backup-manifest-<id>.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
+164
View File
@@ -0,0 +1,164 @@
# 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
€1030/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/<year>/`.
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.
+106
View File
@@ -0,0 +1,106 @@
# 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/
└── <Family-Name>/
├── 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 `<weight>.woff2` where `<weight>` 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/<Family-Name>/`.
6. Keep the OFL license file alongside (the static handler serves anything in the folder, so `/fonts/<Family-Name>/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/<Family-Name>/<weight>.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 `<style id="self-hosted-fonts">` element. Other families are not loaded for that visitor.
- **Bootstrap**: `frontend/src/index.css` ships static `@font-face` blocks for Inter so the very first paint already has the default body font.
## Caveats and edge cases
- **Empty folder** (no `<weight>.woff2` files) → silently skipped, warning in backend logs.
- **Two folders that normalize to the same family** on case-insensitive filesystems (macOS APFS) → second is skipped, warning logged.
- **Weight files with non-numeric names** (e.g. `bold.woff2`, `regular.woff2`) → ignored; family entry still includes its other weights.
- **Removing the `Inter/` folder** → the very-first-paint bootstrap CSS in `index.css` will 404 the font requests; browsers fall back to the next family in `--font-family` (Noto Sans → system-ui). Cosmetic only; no other breakage.
- **Variable fonts** are not supported in v1. Each weight must be a separate file.
- **Italics** are not exposed in the picker.
- **Per-option dropdown previews** (each font name rendered in its own face inside the picker) are not supported in v1. Browser support for styling `<option>` elements is inconsistent — Safari ignores it in the popup entirely, and Chrome/Firefox were unreliable in testing. A future improvement is to replace the native `<select>` with a custom dropdown component or to render a separate "preview text" box below the picker.
### Replacing an existing font
Browsers cache font files for up to 7 days. When you overwrite an existing weight file (e.g. swap your `Inter/400.woff2` for a different cut), some visitors may keep seeing the old face for up to a week, even after a backend restart.
Two ways to roll out a replacement:
1. **Wait it out.** Without `immutable` on the cache header, browsers send `If-Modified-Since` once the 7-day window expires; the backend responds based on file mtime, so the new file gets picked up automatically the next time each client revisits the gallery.
2. **Force-bust the cache by renaming the family folder.** Move `Inter/``Inter-v2/` (with the new file inside) and update the affected event themes to use `Inter v2`. The new folder is served from a new URL, so caches don't apply and every client picks up the new face on next page load. This is the right approach when you need an immediate, gallery-wide rollout.
The first option is fine for cosmetic touch-ups; the second is what to do when a font replacement is genuinely urgent.
## License
The bundled fonts are all SIL Open Font License v1.1 (OFL). The license text and per-font copyright notices live at `backend/assets/fonts/LICENSE-OFL.txt` and are publicly served at `/fonts/LICENSE-OFL.txt`.
If you redistribute the PicPeak Docker image, you redistribute these fonts too — you must keep the LICENSE-OFL.txt file accessible. The default static handler does this for you.
+74
View File
@@ -0,0 +1,74 @@
# Live Slideshow ("Diashow")
The Live Slideshow is a dedicated, token-only, **fullscreen** URL for an event that **auto-picks-up newly uploaded photos while it runs** — designed for a projector or screen at a live event (weddings, concerts, parties). Guests at the venue watch the photos appear in near real time; the photographer keeps culling and uploading from the back of the room.
It is separate from the normal guest gallery: its own link, no gallery password, no chrome — just the photos.
## Table of contents
- [Enable the feature](#enable-the-feature)
- [Create a slideshow link](#create-a-slideshow-link)
- [Run it on a projector](#run-it-on-a-projector)
- [Global defaults (Settings → Slideshow)](#global-defaults-settings--slideshow)
- [Per-event options](#per-event-options)
- [How live updates work](#how-live-updates-work)
- [Good to know](#good-to-know)
## Enable the feature
Live Slideshow is **off by default**. Turn it on under **Settings → Features → Live Slideshow**. While it's off, no slideshow UI appears and any existing slideshow link returns "not active".
## Create a slideshow link
1. Open the event, find the **Live Slideshow** card.
2. Click **Generate slideshow link**. This mints a unique link of the form:
```
https://your-host/gallery/<event-slug>/show/<token>
```
3. **Copy** it (or **Regenerate** to rotate the token and kill the old link, or **Disable** to remove it). The link is a secret — the token *is* the access; there is no separate password.
The link only shows **published, non-hidden** photos — the same set guests see.
## Run it on a projector
Open the link on the machine driving the projector. You'll see a **▶ Start slideshow** splash. Click it once — browsers only allow fullscreen in response to a click — and it goes fullscreen and starts cycling. From then on it runs unattended: cursor hides, it loops at the end, and shows a "Waiting for photos…" screen if the event has none yet.
## Global defaults (Settings → Slideshow)
The picpeak-wide look and feel lives in one place: **Settings → Slideshow**. These apply to every slideshow.
- **Default style for new slideshows** — the transition, display time, transition speed and color filter that **new events inherit**. Each event can still override these.
- Transitions: **Crossfade, Cut, Slide, Ken Burns, Dip to white, Dip to black**.
- Color filters: None, Black & White, Sepia, Warm, Cool, Vignette.
- **Image fit** — **Fill screen (crop)** or **Black bars (no crop)**. Use black bars if your set is portrait-heavy and you don't want faces cropped. *(Live — applies to running slideshows immediately.)*
- **Watermark** — overlay a logo in a corner, TV-station-ident style.
- **Logo**: your light logo, dark-mode logo, favicon, or the event's own logo (shown with a live preview).
- **Style**: **White** (recolors a dark/transparent logo white) or **Original colours** (for a logo that already has its own colours/box).
- **Position**, **opacity**, and **size**.
*(Live — applies immediately.)*
## Per-event options
On the event's Live Slideshow card you can:
- Generate / copy / regenerate / disable the link.
- Override the **display style** (transition, timing, color filter) for this event.
- Override the **watermark**: **Use global default**, **On**, or **Off** — e.g. turn the watermark off for one sensitive event without changing your global setting.
## How live updates work
While a slideshow is running it polls a lightweight endpoint every few seconds:
- **New uploads are appended quietly** at the end — the current slide is never interrupted or skipped.
- **Settings changes apply live** — change the display time, transition, image fit or watermark and the running projector picks it up within a few seconds. No need to regenerate the link or restart.
- **Disabling the feature (or the link) kills it** — turning off the **Live Slideshow** feature flag, or disabling/regenerating the link, makes the projector stop on its next poll.
## Good to know
- **The token is the secret.** Anyone with the link can view the slideshow — **published photos only**: a slideshow link is display-only and cannot download, upload, or post feedback. Rotate it with **Regenerate** if it leaks; **Disable** removes it entirely.
- **Regenerate / Disable is not instant revocation.** A running projector stops within one poll, but the browser session it already opened keeps working for **up to 12 hours** on the old token (there's no token-revocation list — same as gallery passwords). For a hard cut-off, also turn the **Live Slideshow** feature flag off, which denies every link immediately.
- **Fullscreen needs the first click.** The ▶ splash exists because browsers require a user gesture to enter fullscreen — unavoidable, and harmless for a projector.
- **Slideshow views don't pollute analytics.** The projector is excluded from your event's visitor view/download counts.
- **Turning the feature off suspends, doesn't destroy.** Existing links stop working while the flag is off and resume when you turn it back on — the token isn't deleted.
+90
View File
@@ -0,0 +1,90 @@
# Migration: PicPeak moved to its own GitHub organization
PicPeak's repository moved from the maintainer's personal handle to a dedicated
GitHub organization. This is a one-time, operator-facing change. The software
itself is unchanged; only the URLs you pull images from have moved.
## TL;DR — what changed and what you need to do
| | Before | After |
|---|---|---|
| **Repository URL** | `github.com/the-luap/picpeak` | `github.com/PicPeak/picpeak` |
| **Docker images** | `ghcr.io/the-luap/picpeak/{backend,frontend}` | `ghcr.io/picpeak/picpeak/{backend,frontend}` |
| **Branches (active dev)** | `beta` | `main` |
| **Branches (stable channel)** | `main` | `stable` |
**Action required**: update your `docker-compose.yml` to pull from
`ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path no longer serves
images.
## Why this changed
The repo lived under a personal GitHub handle since the project began. Moving to
an organization is a one-time housekeeping step that:
- Separates the project's identity from any individual maintainer's account.
- Lets the project add additional maintainers later without re-transferring.
- Matches the convention every other open-source project uses for branch names
(`main` = active development, `stable` = curated production channel).
## docker-compose.yml — exact edit
Find these two lines:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
# ...
image: ghcr.io/the-luap/picpeak/frontend:${PICPEAK_CHANNEL:-stable}
```
Replace with:
```yaml
image: ghcr.io/picpeak/picpeak/backend:${PICPEAK_CHANNEL:-stable}
# ...
image: ghcr.io/picpeak/picpeak/frontend:${PICPEAK_CHANNEL:-stable}
```
Then:
```bash
docker compose pull
docker compose up -d
```
That's it. No data migration, no config changes, no database changes.
## What auto-redirects (you don't have to change)
GitHub redirects the old repo URL indefinitely, so these all keep working:
- Browser links to `github.com/the-luap/picpeak/...` (issues, PRs, files)
- `git clone https://github.com/the-luap/picpeak.git`
- GitHub API calls to `api.github.com/repos/the-luap/picpeak/...`
Worth updating to the canonical `PicPeak/picpeak` form when convenient, but
nothing breaks if you don't.
## What does NOT auto-redirect
- **GHCR image paths.** `ghcr.io/the-luap/picpeak/*` returns **404** — you must
update your compose file.
## Branch rename
The active-development branch was renamed from `beta``main`, and the previous
`main` (stable channel) was renamed to `stable`. This matches the convention
every other open-source project uses.
If you're a contributor:
- **Feature PRs**: target `main`.
- **Bugfix PRs**: target `main`. If the fix also needs to ship to current stable
users, open a separate small PR against `stable`.
If you're an operator: ignore. Branch names don't affect pulls.
## Got stuck?
Open an issue at https://github.com/PicPeak/picpeak/issues with the error
message and we'll help.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB