screenshot for date-input crash fix
This commit is contained in:
@@ -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 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/<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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
€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/<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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
Reference in New Issue
Block a user