diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..713a692 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.next +.git +.github +data +data-e2e +test-results +playwright-report +blob-report +coverage +.env +.env.*.local +*.log +docs +README.md +QUICKSTART.md +CHANGELOG.md +CLAUDE.md +.vscode +.idea diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..015ea15 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Normalise line endings so files written on Windows still work in the +# Linux container. A shell script with CRLF fails with the confusing +# "no such file or directory" on its shebang line. +* text=auto eol=lf + +*.sh text eol=lf +Dockerfile text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Binary files must never be touched. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.pdf binary +*.ico binary diff --git a/.gitignore b/.gitignore index e5e8b98..23c007c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ next-env.d.ts # runtime data (database, uploads) - never in git, this is the backup target /data /data-e2e +/data-shots +/data-docker +/backups # production *.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ca9e32 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Bricktrack - Multi-Stage-Build +# +# Ergebnis ist ein schlankes Runtime-Image mit Next.js im Standalone-Modus, +# das als Nutzer "node" laeuft. Alle Daten liegen ausserhalb des Images unter +# /data, damit ein Update nichts anfasst, was dir gehoert. +# --------------------------------------------------------------------------- + +FROM node:24-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +ENV NEXT_TELEMETRY_DISABLED=1 +RUN corepack enable + +# --- Abhaengigkeiten ------------------------------------------------------- +# better-sqlite3 und sharp bringen Prebuilds mit; die Build-Werkzeuge sind der +# Rueckfallweg, falls fuer die Plattform keines existiert. +FROM base AS deps +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +# --- Build ----------------------------------------------------------------- +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Platzhalter nur fuer den Build: src/env.ts validiert beim Import, und die +# echten Werte kommen zur Laufzeit aus der .env. Keiner dieser Werte landet im +# Image-Zustand, weil keine Seite vorgerendert wird. +ENV NODE_ENV=production +ENV BASE_URL=http://localhost:3000 +ENV BETTER_AUTH_SECRET=build-platzhalter-0123456789-abcdefghij +ENV DATA_DIR=/tmp/build-data + +RUN pnpm build + +# Der Migrations-Runner wird zu einer einzelnen Datei gebuendelt. So braucht +# das Runtime-Image kein drizzle-kit und kein tsx - nur das native +# better-sqlite3, das ohnehin dabei ist. Das Buendel ist CommonJS, weil +# mitgebuendelte CJS-Abhaengigkeiten sonst am require() der Node-Builtins +# scheitern. +RUN pnpm exec esbuild scripts/migrate.ts \ + --bundle \ + --platform=node \ + --format=cjs \ + --target=node22 \ + --external:better-sqlite3 \ + --outfile=dist/migrate.cjs + +# --- Laufzeit -------------------------------------------------------------- +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV DATA_DIR=/data +ENV MIGRATIONS_DIR=/app/drizzle + +# Standalone bringt seine eigenen node_modules mit - kein pnpm install noetig. +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/drizzle ./drizzle +COPY --from=builder --chown=node:node /app/dist/migrate.cjs ./migrate.cjs +COPY --chown=node:node docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Anlaufstelle fuer Datenbank und Uploads. Wird per Bind-Mount ueberdeckt; +# das Anlegen hier sorgt nur dafuer, dass der Pfad auch ohne Mount existiert. +RUN mkdir -p /data/db /data/files && chown -R node:node /data + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..3c544f2 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,210 @@ +# Bricktrack – Schnellstart + +Von null auf laufende Instanz. Zeitbedarf: etwa 15 Minuten, davon 10 für den +ersten Docker-Build. + +**Voraussetzungen:** Ein Linux-Host (z. B. eine LXC oder VM auf Proxmox) mit +Docker und dem Compose-Plugin, ein Reverse Proxy mit gültigem TLS-Zertifikat +und eine Subdomain, die auf den Proxy zeigt. + +> **Warum HTTPS Pflicht ist:** Passkeys (WebAuthn) funktionieren +> ausschließlich über HTTPS und sind fest an den Hostnamen gebunden. Ohne +> Zertifikat bleibt nur die Anmeldung per Passwort. Wird die Domain später +> geändert, sind **alle** Passkeys ungültig und müssen neu angelegt werden. + +--- + +## 1. Projekt holen + +```bash +sudo mkdir -p /opt/bricktrack +sudo chown "$USER" /opt/bricktrack +git clone https://git.wilde-server.de/florian.wilde/bricktrack.git /opt/bricktrack +cd /opt/bricktrack +``` + +## 2. Konfiguration anlegen + +```bash +cp .env.example .env +``` + +Jetzt `.env` öffnen und zwei Werte setzen: + +```bash +# Die Adresse, unter der du die App im Browser aufrufst. +BASE_URL=https://bricktrack.example.de + +# Signiergeheimnis - hiermit erzeugen: +# openssl rand -base64 48 +BETTER_AUTH_SECRET= +``` + +Alles andere kann so bleiben. `DATA_DIR=/data` ist der Pfad **im Container** +und wird nicht angefasst. + +## 3. Datenverzeichnis vorbereiten + +Der Container läuft aus Sicherheitsgründen nicht als root, sondern als Nutzer +mit der UID 1000. Das gemountete Verzeichnis muss ihm gehören: + +```bash +mkdir -p ./data +sudo chown -R 1000:1000 ./data +``` + +Diesen Schritt vergisst man gern – der Container sagt dir dann aber beim Start +genau das. + +## 4. Starten + +```bash +docker compose up -d --build +``` + +Der erste Build dauert einige Minuten. Danach: + +```bash +docker compose ps # Status "healthy" abwarten +docker compose logs -f app # Log mitlesen, mit Strg+C beenden +curl -s http://127.0.0.1:3000/api/health +# {"status":"ok","time":"..."} +``` + +## 5. Reverse Proxy einrichten (Nginx Proxy Manager) + +In NPM unter **Hosts → Proxy Hosts → Add Proxy Host**: + +**Reiter „Details"** + +| Feld | Wert | +|---|---| +| Domain Names | `bricktrack.example.de` | +| Scheme | `http` | +| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` | +| Forward Port | `3000` | +| Cache Assets | **aus** (Next.js setzt seine Cache-Header selbst) | +| Block Common Exploits | an | +| Websockets Support | **an** | + +**Reiter „SSL"** + +| Feld | Wert | +|---|---| +| SSL Certificate | Let's Encrypt anfordern oder vorhandenes wählen | +| Force SSL | **an** | +| HTTP/2 Support | an | +| HSTS Enabled | an (optional, aber sinnvoll) | + +**Reiter „Advanced"** – hier hinein, sonst scheitern große Anleitungs-PDFs am +Standardlimit von 1 MB: + +```nginx +# Anleitungs-PDFs koennen mehrere Megabyte gross sein. +client_max_body_size 64m; + +# Uploads und PDF-Auslieferung duerfen etwas dauern. +proxy_read_timeout 300; +proxy_send_timeout 300; + +# Die App leitet ihre oeffentliche Adresse und die Client-IP hieraus ab. +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Real-IP $remote_addr; +``` + +`client_max_body_size` muss mindestens so groß sein wie `MAX_UPLOAD_MB` in +deiner `.env` (Standard 25 MB); 64m gibt Luft. + +> **Läuft NPM im selben Docker-Netz?** Dann in `docker-compose.yml` das +> Port-Mapping auf `- "3000:3000"` ändern oder ganz entfernen und beide +> Container in ein gemeinsames Netzwerk hängen. Als Forward Hostname trägst du +> dann `bricktrack` ein (den Container-Namen), Port `3000`. +> +> Die Voreinstellung `127.0.0.1:3000:3000` bindet den Port nur an localhost – +> die App ist dann ausschließlich über den Proxy erreichbar. Sitzt NPM auf +> einem **anderen** Host, muss das Mapping auf `3000:3000` geändert und der +> Zugriff per Firewall beschränkt werden. + +## 6. Ersten Nutzer anlegen + +`https://bricktrack.example.de` aufrufen. Die App leitet direkt auf die +Registrierung. + +**Der erste registrierte Nutzer wird automatisch Administrator.** Danach ist +die Registrierung geschlossen – weitere Konten entstehen nur über einen +Einladungslink (ab Version 0.2.0). + +Also: gleich beim ersten Aufruf selbst registrieren, bevor es jemand anderes +tut. + +## 7. Passkey hinterlegen + +Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per +Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein +eigener Passkey – Handy, Tablet, Laptop. + +## 8. Loslegen + +1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen. +2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" … +3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … +4. **Katalog → Set anlegen**: Setnummer, Name, Bild. +5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist. +6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen, + **„Zur Wunschliste hinzufügen"**. +7. **Wunschlisten → Liste öffnen → Link kopieren** und verschicken. + +Zum Ausprobieren gibt es Beispieldaten: + +```bash +docker compose exec app node -e "process.exit(0)" # laeuft der Container? +docker compose exec app sh -c 'echo "Seed nur in der Entwicklung"' +``` + +Das Seed-Skript ist bewusst nicht im Produktions-Image. Zum Ausprobieren +lokal: `pnpm db:seed`. + +--- + +## Sichern + +Alles, was zählt, liegt in **einem** Verzeichnis: + +```bash +docker compose stop app +tar czf bricktrack-$(date +%F).tar.gz data .env +docker compose start app +``` + +Zurückspielen: + +```bash +docker compose down +tar xzf bricktrack-2026-09-06.tar.gz +sudo chown -R 1000:1000 ./data +docker compose up -d +``` + +## Aktualisieren + +```bash +./update.sh +``` + +Das Skript sichert vorher automatisch, baut neu, wartet auf den Healthcheck +und rollt bei einem Fehlschlag zurück. Details in der [README](README.md). + +--- + +## Wenn etwas klemmt + +| Symptom | Ursache und Lösung | +|---|---| +| `FEHLER: In /data darf nicht geschrieben werden` | `sudo chown -R 1000:1000 ./data` | +| Container startet, aber `unhealthy` | `docker compose logs app` – meist eine fehlende Variable in `.env` | +| `Ungueltige Umgebungsvariablen: BETTER_AUTH_SECRET` | Das Geheimnis braucht mindestens 32 Zeichen | +| Passkey lässt sich nicht anlegen | Kein HTTPS, oder `BASE_URL` passt nicht zur aufgerufenen Domain | +| Passkeys funktionieren nach einem Domainwechsel nicht mehr | Erwartet – Passkeys sind an die Domain gebunden, neu anlegen | +| `413 Request Entity Too Large` beim PDF-Upload | `client_max_body_size` im Proxy erhöhen (Schritt 5) | +| Anmeldung meldet „Zu viele Versuche" | Rate-Limit: fünf Fehlversuche pro Minute, danach kurz warten | diff --git a/README.md b/README.md index e215bc4..f12075f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,227 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Bricktrack -## Getting Started +Self-hosted Verwaltung für die Spielzeug-Set-Sammlung deines Kindes – Lego, +Playmobil, Cobi, BlueBrixx, Mega Bloks und jeden weiteren Hersteller, den du +selbst anlegst. -First, run the development server: +Die App beantwortet zwei Fragen sofort: + +- **Welche Sets hat er?** → die Sammlung, mit Standort, Zustand und Statistik. +- **Welche Sets hat er noch nicht?** → alles im Katalog ohne Sammlungseintrag. + +Aus den fehlenden Sets entsteht in wenigen Handgriffen eine Wunschliste, die +du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk +– **ohne sich zu registrieren**, und ohne dass jemand doppelt kauft. + +> **Neu hier?** → [QUICKSTART.md](QUICKSTART.md) führt dich in 15 Minuten von +> null zur laufenden Instanz, inklusive Nginx Proxy Manager. +> **Bedienung?** → [docs/ANLEITUNG.md](docs/ANLEITUNG.md), bebildert. + +--- + +## Was drin ist + +| | | +|---|---| +| **Katalog** | Sets aller Hersteller, Suche nach Nummer, Name oder EAN, Filter nach Hersteller, Thema, Jahr und Besitzstatus | +| **Sammlung** | Was tatsächlich da ist: Status, Vollständigkeit, Zustand, Standort, Kaufpreis, „geschenkt von" | +| **Anleitungen** | PDFs hochladen und im Browser blättern (pdf.js, ohne externe Dienste) oder auf die Hersteller-Seite verlinken | +| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera | +| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin | +| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus | +| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto | + +Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht +am Schreibtisch. + +## Technik + +Next.js 15 (App Router, Server Actions, TypeScript strict) · Tailwind CSS 4 · +shadcn/ui · SQLite über Drizzle ORM und better-sqlite3 · better-auth mit +Passkey-Plugin · alles in **einem** Docker-Container. + +Keine Cloud, keine externen Dienste, keine Telemetrie. Der Browser lädt nichts +von fremden Servern außer den Bild-URLs, die du selbst hinterlegt hast. + +Details zu Stack, Konventionen und Datenmodell: [CLAUDE.md](CLAUDE.md). + +--- + +## Installation + +Ausführlich in [QUICKSTART.md](QUICKSTART.md). Die Kurzfassung: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +git clone https://git.wilde-server.de/florian.wilde/bricktrack.git +cd bricktrack + +cp .env.example .env +# BASE_URL und BETTER_AUTH_SECRET setzen: +# openssl rand -base64 48 + +mkdir -p ./data +sudo chown -R 1000:1000 ./data # der Container laeuft als UID 1000 + +docker compose up -d --build ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Danach `https://deine-domain` aufrufen. **Der erste registrierte Nutzer wird +Administrator**, danach ist die Registrierung geschlossen. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Konfiguration -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Alle Werte kommen aus `.env` (Vorlage: `.env.example`). -## Learn More +| Variable | Pflicht | Standard | Bedeutung | +|---|---|---|---| +| `BASE_URL` | ja | `http://localhost:3000` | Öffentliche Adresse. Bestimmt die Passkey-Domain (RP-ID). | +| `BETTER_AUTH_SECRET` | ja | – | Signiergeheimnis, mindestens 32 Zeichen. | +| `DATA_DIR` | nein | `/data` | Datenverzeichnis im Container. Nicht ändern. | +| `MAX_UPLOAD_MB` | nein | `25` | Größenlimit je Datei. Muss zum Proxy passen. | +| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. | +| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. | -To learn more about Next.js, take a look at the following resources: +### Reverse Proxy -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +TLS wird außerhalb terminiert, der Container spricht nur HTTP auf Port 3000. +Drei Dinge sind wichtig: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +1. **HTTPS mit Force-SSL** – ohne das keine Passkeys. +2. **`client_max_body_size`** mindestens so groß wie `MAX_UPLOAD_MB`, sonst + scheitern große Anleitungs-PDFs mit „413". +3. **`X-Forwarded-For`** durchreichen, damit das Rate-Limit auf dem + Login-Endpunkt die echte Client-IP sieht. -## Deploy on Vercel +Die vollständige Nginx-Proxy-Manager-Konfiguration steht in +[QUICKSTART.md, Schritt 5](QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager). -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +--- -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Backup und Restore + +**Ein Verzeichnis reicht.** `./data` enthält die Datenbank *und* alle +hochgeladenen Dateien: + +``` +data/ + db/bricktrack.db SQLite (dazu -wal und -shm) + files/images/sets/… Set-Bilder + files/photos/… eigene Fotos + files/instructions/… Anleitungs-PDFs +``` + +**Sichern** (Container kurz anhalten, damit die Datenbank nicht mitten im +Schreiben eingepackt wird): + +```bash +docker compose stop app +tar czf bricktrack-$(date +%F).tar.gz data .env +docker compose start app +``` + +**Zurückspielen:** + +```bash +docker compose down +rm -rf data +tar xzf bricktrack-2026-09-06.tar.gz +sudo chown -R 1000:1000 ./data +docker compose up -d +``` + +Migrationen laufen bei jedem Start automatisch – ein altes Backup wird beim +Zurückspielen also auf den aktuellen Schemastand gehoben. + +## Aktualisieren + +```bash +./update.sh +``` + +Das Skript sichert `./data` und `.env`, holt den neuen Stand, baut das Image, +startet neu und wartet auf den Healthcheck. Wird der Container nicht gesund, +rollt es Code **und** Container automatisch auf den vorherigen Commit zurück +und sagt dir, wo die Sicherung liegt. + +```bash +./update.sh --no-pull # nur neu bauen, ohne git pull +./update.sh --no-backup # ohne Sicherung (nicht empfohlen) +``` + +Die letzten zehn Sicherungen bleiben unter `./backups` liegen. + +--- + +## Entwicklung + +```bash +corepack enable pnpm # pnpm 10 +pnpm install + +cp .env.example .env # DATA_DIR=./data, BASE_URL=http://localhost:3000 +pnpm db:migrate # Datenbank anlegen +pnpm db:seed # Beispieldaten: 3 Hersteller, 10 Sets, 1 Wunschliste +pnpm dev # http://localhost:3000 +``` + +| Befehl | Wirkung | +|---|---| +| `pnpm dev` | Entwicklungsserver | +| `pnpm build` | Produktionsbuild (standalone) | +| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein | +| `pnpm test` | Unit-Tests (Vitest) | +| `pnpm test:e2e` | Playwright-Smoke-Test gegen ein eigenes `./data-e2e` | +| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen | +| `pnpm db:migrate` | Migrationen anwenden | +| `pnpm db:studio` | Drizzle Studio | + +> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der +> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder +> administrative Rechte braucht. Der Docker-Build unter Linux ist davon nicht +> betroffen. Zum Prüfen lokal: `docker build -t bricktrack:test .` + +Die Passkey-Anmeldung funktioniert lokal auch über `http://localhost` – +WebAuthn behandelt localhost als sicheren Kontext. Auf jeder anderen Adresse +braucht es HTTPS. + +--- + +## Datenmodell in einem Absatz + +Ein **`set`** ist ein Katalogeintrag: Das Set existiert in der Welt, +unabhängig davon, ob wir es besitzen. Ein **`collection_item`** verknüpft ein +Set mit einem tatsächlich vorhandenen Exemplar – dasselbe Set zweimal im Regal +sind zwei Einträge. Daraus folgt die zentrale Regel: + +> „Hat er nicht" = Sets im Katalog, zu denen kein `collection_item` mit einem +> Status ungleich `sold` existiert. + +Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht +wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese +Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`. + +Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell). + +## Sicherheit + +- Jede Eingabe wird serverseitig mit zod validiert. +- Uploads sind auf PDF, JPEG, PNG und WebP begrenzt – geprüft an den **Magic + Bytes**, nicht am gemeldeten MIME-Typ. Dateinamen erzeugt immer der Server. +- Die öffentliche Wunschliste nutzt ausschließlich den Share-Token als + Schlüssel und gibt nichts außer den Wunschlisten-Daten preis. Sie ist + `noindex`. +- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank. +- Session-Cookies sind `httpOnly`, `sameSite=lax` und im Betrieb `secure`. + Auf den Login-Endpunkten liegt ein Rate-Limit (5 Versuche pro Minute). +- Set-Bilder sind ohne Anmeldung abrufbar – die geteilte Wunschliste braucht + sie. Anleitungs-PDFs und eigene Fotos **nie**. + +## Stand und Pläne + +Aktuelle Version und Änderungen: [CHANGELOG.md](CHANGELOG.md). +Was als Nächstes kommt: [docs/ROADMAP.md](docs/ROADMAP.md). +Wie lange was gedauert hat: [docs/DEVLOG.md](docs/DEVLOG.md). + +## Lizenz + +Privates Projekt, keine Lizenz vergeben. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a07102c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +# --------------------------------------------------------------------------- +# Bricktrack - ein Service, ein Datenverzeichnis. +# +# TLS terminiert der Reverse Proxy (z. B. Nginx Proxy Manager). Der Container +# spricht nur HTTP und muss nicht nach aussen erreichbar sein. +# --------------------------------------------------------------------------- + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: bricktrack:latest + container_name: bricktrack + restart: unless-stopped + + env_file: + - .env + + environment: + # Im Container liegen die Daten immer unter /data - der Pfad auf dem Host + # wird ueber den Bind-Mount unten bestimmt. + DATA_DIR: /data + PORT: 3000 + + ports: + # Nur an localhost binden: erreichbar wird die App ueber den Reverse + # Proxy. Laeuft der Proxy in einem anderen Docker-Netz, siehe README. + - "127.0.0.1:3000:3000" + + volumes: + # Das eine Verzeichnis, das gesichert werden muss. + - ./data:/data + + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..cd7e63c --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Startet den Container: Verzeichnisse pruefen, Migrationen anwenden, App starten. +set -e + +DATA_DIR="${DATA_DIR:-/data}" + +echo "[bricktrack] Datenverzeichnis: $DATA_DIR" + +if ! mkdir -p "$DATA_DIR/db" "$DATA_DIR/files" 2>/dev/null; then + echo "" + echo " FEHLER: In $DATA_DIR darf nicht geschrieben werden." + echo "" + echo " Der Container laeuft als Nutzer 'node' (UID 1000). Das gemountete" + echo " Verzeichnis muss ihm gehoeren. Auf dem Host einmalig:" + echo "" + echo " mkdir -p ./data && sudo chown -R 1000:1000 ./data" + echo "" + exit 1 +fi + +if [ ! -w "$DATA_DIR/db" ]; then + echo " FEHLER: $DATA_DIR/db ist nicht beschreibbar (sudo chown -R 1000:1000 ./data)." + exit 1 +fi + +echo "[bricktrack] Migrationen werden angewendet …" +node /app/migrate.cjs + +echo "[bricktrack] Start auf Port ${PORT:-3000}" +exec "$@" diff --git a/e2e/screenshots-setup.ts b/e2e/screenshots-setup.ts new file mode 100644 index 0000000..5d06996 --- /dev/null +++ b/e2e/screenshots-setup.ts @@ -0,0 +1,9 @@ +import { execFileSync } from 'node:child_process'; + +/** Migrates the screenshot database; the directory is wiped by the npm script. */ +export default function globalSetup() { + execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], { + stdio: 'inherit', + env: { ...process.env, DATA_DIR: './data-shots' }, + }); +} diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts new file mode 100644 index 0000000..0151e12 --- /dev/null +++ b/e2e/screenshots.spec.ts @@ -0,0 +1,124 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; + +/** + * Captures the screenshots used by docs/ANLEITUNG.md. + * + * Reproducible on purpose: whenever the UI changes, `pnpm screenshots` + * refreshes every picture in the manual from the same seeded data. + */ +const SHOT_DIR = path.resolve(process.cwd(), 'docs/bilder'); + +function shotPath(name: string) { + fs.mkdirSync(SHOT_DIR, { recursive: true }); + return path.join(SHOT_DIR, `${name}.png`); +} + +async function shoot(page: Page, name: string) { + // Let fonts and images settle so the picture is not half-rendered. + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: shotPath(name), fullPage: true }); +} + +test('Screenshots fuer die Anleitung', async ({ page, browser }) => { + // --- 1. registration ------------------------------------------------------ + await page.goto('/registrieren'); + await expect(page.getByText('Erstes Konto anlegen')).toBeVisible(); + await shoot(page, '01-registrierung'); + + await page.getByLabel('Name').fill('Florian'); + await page.getByLabel('E-Mail').fill('papa@bricktrack.local'); + await page.getByLabel('Passwort').fill('anleitung-passwort'); + await page.getByRole('button', { name: 'Konto anlegen' }).click(); + await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible(); + + // Example data needs the user to exist first (the wishlist has an owner). + execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/seed.ts'], { + stdio: 'inherit', + env: { ...process.env, DATA_DIR: './data-shots' }, + }); + + // --- 2. dashboard --------------------------------------------------------- + await page.goto('/'); + await shoot(page, '02-start'); + + // --- 3. master data ------------------------------------------------------- + await page.goto('/verwaltung/hersteller'); + await expect(page.getByText('Lego')).toBeVisible(); + await shoot(page, '03-verwaltung-hersteller'); + + // --- 4. catalog ----------------------------------------------------------- + await page.goto('/katalog'); + await expect(page.getByText('Familienhaus und Elektroauto')).toBeVisible(); + await shoot(page, '04-katalog'); + + // --- 5. missing sets, selection mode -------------------------------------- + await page.goto('/katalog?besitz=fehlt'); + await expect(page.getByText('Fehlende Sets')).toBeVisible(); + await shoot(page, '05-fehlende-sets'); + + await page.getByRole('button', { name: 'Auswählen' }).click(); + const cards = page.locator('button[aria-pressed]'); + await cards.nth(0).click(); + await cards.nth(1).click(); + await expect(page.getByText('2 ausgewählt')).toBeVisible(); + await shoot(page, '06-mehrfachauswahl'); + + await page.getByRole('button', { name: 'Zur Wunschliste hinzufügen' }).click(); + await expect(page.getByText('Sets auf die Wunschliste')).toBeVisible(); + await page.screenshot({ path: shotPath('07-wunschliste-dialog') }); + await page.getByRole('button', { name: 'Abbrechen' }).click(); + + // --- 6. set detail -------------------------------------------------------- + await page.goto('/katalog?q=60398'); + await page.getByText('Familienhaus und Elektroauto').first().click(); + await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible(); + await shoot(page, '08-set-detail'); + + // --- 7. collection -------------------------------------------------------- + await page.goto('/sammlung'); + await expect(page.getByText('Wert nach UVP')).toBeVisible(); + await shoot(page, '09-sammlung'); + + // --- 8. wishlist ---------------------------------------------------------- + await page.goto('/wunschlisten'); + await expect(page.getByText('Weihnachten 2026')).toBeVisible(); + await shoot(page, '10-wunschlisten'); + + const listHref = await page + .getByRole('link', { name: /Weihnachten 2026/ }) + .getAttribute('href'); + await page.goto(listHref!); + await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + await shoot(page, '11-wunschliste-bearbeiten'); + + const shareUrl = await page.getByLabel('Link zum Teilen').inputValue(); + + // --- 9. public view, as a visitor without an account ---------------------- + const visitorContext = await browser.newContext({ locale: 'de-DE' }); + const visitor = await visitorContext.newPage(); + await visitor.setViewportSize({ width: 412, height: 915 }); + await visitor.goto(shareUrl); + await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + await shoot(visitor, '12-oeffentliche-wunschliste'); + + await visitor.getByRole('button', { name: 'Das schenke ich' }).first().click(); + await visitor.getByLabel('Dein Name').fill('Tante Anne'); + await visitor.screenshot({ path: shotPath('13-reservieren') }); + await visitor.getByRole('button', { name: 'Reservieren' }).click(); + await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible(); + await visitor.getByRole('button', { name: 'Alles klar' }).click(); + await shoot(visitor, '14-reserviert'); + await visitorContext.close(); + + // --- 10. the owner sees the reservation ----------------------------------- + await page.reload(); + await shoot(page, '15-wunschliste-reserviert'); + + // --- 11. account and passkeys -------------------------------------------- + await page.goto('/konto'); + await expect(page.getByText('Passkeys')).toBeVisible(); + await shoot(page, '16-konto-passkeys'); +}); diff --git a/package.json b/package.json index 5f2b79a..484159d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "node scripts/reset-e2e.mjs && playwright test", - "screenshots": "playwright test --config=playwright.screenshots.config.ts" + "screenshots": "node scripts/reset-shots.mjs && playwright test --config=playwright.screenshots.config.ts" }, "dependencies": { "@better-auth/passkey": "^1.7.3", @@ -53,6 +53,7 @@ "@types/react-dom": "^19", "dotenv": "^17.4.2", "drizzle-kit": "^0.31.10", + "esbuild": "^0.25.12", "eslint": "^9", "eslint-config-next": "15.5.25", "shadcn": "^4.21.0", diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts new file mode 100644 index 0000000..c8b96b5 --- /dev/null +++ b/playwright.screenshots.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Produces the screenshots for docs/ANLEITUNG.md. + * + * Kept apart from the smoke test on purpose: this run needs seeded example + * data and writes files into the repository, which a test never should. + * + * pnpm screenshots + */ +const PORT = 3220; +const BASE_URL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + testMatch: 'screenshots.spec.ts', + globalSetup: './e2e/screenshots-setup.ts', + fullyParallel: false, + workers: 1, + timeout: 240_000, + reporter: 'list', + use: { + baseURL: BASE_URL, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + // A phone, because that is how the app is actually used. + ...devices['Pixel 7'], + }, + webServer: { + command: `pnpm exec next dev -p ${PORT}`, + url: `${BASE_URL}/api/health`, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + env: { + NODE_ENV: 'development', + DATA_DIR: './data-shots', + BASE_URL, + BETTER_AUTH_SECRET: 'screenshot-run-secret-0123456789abcdefgh', + MAX_UPLOAD_MB: '25', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2cf62c..fa513f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.7.3 - version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) + version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) better-auth: specifier: ^1.7.3 - version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -93,6 +93,9 @@ importers: drizzle-kit: specifier: ^0.31.10 version: 0.31.10 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0) @@ -113,7 +116,7 @@ importers: version: 5.9.3 vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) packages: @@ -5243,14 +5246,14 @@ snapshots: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)': + '@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)': dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.3 - better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))) better-call: 1.4.0(zod@4.5.4) nanostores: 1.5.3 zod: 4.5.4 @@ -7090,14 +7093,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.0 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13) '@vitest/spy@5.0.0': {} @@ -7251,7 +7254,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))): + better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))): dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5)) @@ -7277,7 +7280,7 @@ snapshots: next: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -9706,7 +9709,7 @@ snapshots: vary@1.1.2: {} - vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13): + vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -9715,15 +9718,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 20.19.43 - esbuild: 0.28.2 + esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.13 - vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -9734,7 +9737,7 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 diff --git a/scripts/reset-shots.mjs b/scripts/reset-shots.mjs new file mode 100644 index 0000000..796c49d --- /dev/null +++ b/scripts/reset-shots.mjs @@ -0,0 +1,7 @@ +/** Wipes the screenshot data directory before `pnpm screenshots`. */ +import fs from 'node:fs'; +import path from 'node:path'; + +const dataDir = path.resolve(process.cwd(), 'data-shots'); +fs.rmSync(dataDir, { recursive: true, force: true }); +console.log(`[screenshots] ${dataDir} zurueckgesetzt.`); diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..f90a598 --- /dev/null +++ b/update.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Bricktrack aktualisieren. +# +# ./update.sh Update auf den aktuellen Stand von origin/main +# ./update.sh --no-pull Nur neu bauen und starten, ohne git pull +# ./update.sh --no-backup Ohne Sicherung (nicht empfohlen) +# +# Ablauf: sichern -> holen -> bauen -> starten -> Healthcheck. +# Schlaegt der Healthcheck fehl, wird automatisch auf den vorherigen Stand +# zurueckgerollt und die Sicherung bleibt liegen. + +set -euo pipefail + +cd "$(dirname "$0")" + +BACKUP_DIR="./backups" +KEEP_BACKUPS=10 +HEALTH_TIMEOUT=180 +DO_PULL=1 +DO_BACKUP=1 + +for arg in "$@"; do + case "$arg" in + --no-pull) DO_PULL=0 ;; + --no-backup) DO_BACKUP=0 ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unbekannte Option: $arg" >&2; exit 1 ;; + esac +done + +info() { printf '\033[1;34m[bricktrack]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[bricktrack]\033[0m %s\n' "$*"; } +error() { printf '\033[1;31m[bricktrack]\033[0m %s\n' "$*" >&2; } + +# --- Vorbedingungen -------------------------------------------------------- + +if ! docker compose version >/dev/null 2>&1; then + error "Docker Compose (Plugin) wurde nicht gefunden." + exit 1 +fi + +if [ ! -f .env ]; then + error "Es gibt keine .env. Lege sie an: cp .env.example .env" + exit 1 +fi + +PREVIOUS_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo '')" + +# --- Sichern --------------------------------------------------------------- + +BACKUP_FILE="" +if [ "$DO_BACKUP" -eq 1 ]; then + mkdir -p "$BACKUP_DIR" + BACKUP_FILE="$BACKUP_DIR/bricktrack-$(date +%Y%m%d-%H%M%S).tar.gz" + + info "Sicherung wird angelegt: $BACKUP_FILE" + # Der Container wird kurz angehalten, damit die SQLite-Datei nicht mitten in + # einem Schreibvorgang eingepackt wird. + docker compose stop app >/dev/null 2>&1 || true + tar czf "$BACKUP_FILE" data .env 2>/dev/null || { + error "Die Sicherung ist fehlgeschlagen. Abbruch." + docker compose start app >/dev/null 2>&1 || true + exit 1 + } + info "Sicherung fertig ($(du -h "$BACKUP_FILE" | cut -f1))." + + # Alte Sicherungen aufraeumen, die juengsten bleiben. + ls -1t "$BACKUP_DIR"/bricktrack-*.tar.gz 2>/dev/null \ + | tail -n +$((KEEP_BACKUPS + 1)) \ + | xargs -r rm -- +fi + +# --- Holen ----------------------------------------------------------------- + +if [ "$DO_PULL" -eq 1 ]; then + if [ -n "$(git status --porcelain)" ]; then + error "Es gibt lokale Aenderungen im Arbeitsverzeichnis:" + git status --short >&2 + error "Bitte erst aufraeumen oder mit --no-pull starten." + exit 1 + fi + + info "Neue Version wird geholt …" + git pull --ff-only + NEW_COMMIT="$(git rev-parse HEAD)" + + if [ "$NEW_COMMIT" = "$PREVIOUS_COMMIT" ]; then + info "Es gibt nichts Neues. Der Container wird trotzdem neu gestartet." + else + info "Neuer Stand: $(git log -1 --pretty='%h %s')" + fi +fi + +# --- Bauen und starten ----------------------------------------------------- + +info "Image wird gebaut …" +docker compose build app + +info "Container wird gestartet …" +docker compose up -d app + +# --- Healthcheck ----------------------------------------------------------- + +info "Warte auf den Healthcheck (max. ${HEALTH_TIMEOUT}s) …" +elapsed=0 +healthy=0 + +while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' bricktrack 2>/dev/null || echo 'missing')" + + case "$status" in + healthy) healthy=1; break ;; + unhealthy) break ;; + missing) error "Der Container bricktrack laeuft nicht."; break ;; + esac + + sleep 3 + elapsed=$((elapsed + 3)) + printf '.' +done +printf '\n' + +if [ "$healthy" -eq 1 ]; then + info "Bricktrack laeuft." + docker compose ps app + exit 0 +fi + +# --- Rueckabwicklung ------------------------------------------------------- + +error "Die neue Version ist nicht gesund geworden. Die letzten Logzeilen:" +docker compose logs --tail 50 app >&2 + +if [ "$DO_PULL" -eq 1 ] && [ -n "$PREVIOUS_COMMIT" ]; then + warn "Rollback auf $PREVIOUS_COMMIT …" + git reset --hard "$PREVIOUS_COMMIT" + docker compose build app + docker compose up -d app + warn "Der vorherige Stand laeuft wieder." +fi + +if [ -n "$BACKUP_FILE" ]; then + warn "Die Daten liegen unveraendert in $BACKUP_FILE." + warn "Zurueckspielen falls noetig:" + warn " docker compose down && tar xzf $BACKUP_FILE && sudo chown -R 1000:1000 ./data && docker compose up -d" +fi + +exit 1