Vier neue Kapitel in der Anleitung (mehrere Sammler, Preise, Erinnerungen, Dubletten) mit vier automatisch erzeugten Bildern, Changelog 0.3.0 samt der bewusst weggelassenen BrickLink-Anbindung, Roadmap abgehakt und um einen offenen Ausblick ergaenzt, Devlog mit Zeiten und Stolpersteinen. Dazu zwei Korrekturen aus dem Testlauf: Der Zeitplan fuer Erinnerungen liess sich nicht aus Next' instrumentation.ts starten - die Datei wird auch fuer die Edge-Runtime uebersetzt, und das native SQLite-Modul bricht dort den Build. Er startet jetzt aus dem Healthcheck heraus, den der Container ohnehin alle 30 Sekunden aufruft, und zusaetzlich aus dem App-Layout; der Aufruf ist idempotent. Der Screenshot-Lauf waehlte ploetzlich die falschen Kacheln aus: Der neue Sammler-Umschalter benutzt ebenfalls aria-pressed. Die Auswahlkacheln tragen jetzt ein eigenes data-set-card. Ausserdem raeumen die Testskripte einen Container weg, den ein abgebrochener Lauf zurueckgelassen hat - sonst blockiert er den Port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
9.8 KiB
Markdown
216 lines
9.8 KiB
Markdown
# Bricktrack
|
|
|
|
Self-hosted Webanwendung zur Verwaltung einer Spielzeug-Set-Sammlung
|
|
(Lego, Playmobil, Cobi, BlueBrixx, Mega Bloks und beliebige weitere
|
|
Hersteller). Sie beantwortet zwei Fragen sofort: **Welche Sets hat er?**
|
|
und **Welche Sets hat er noch nicht?** Aus den fehlenden Sets entstehen
|
|
teilbare Wunschlisten.
|
|
|
|
## Stack
|
|
|
|
| Bereich | Wahl | Anmerkung |
|
|
|---|---|---|
|
|
| Framework | Next.js 15, App Router, Server Actions | `output: 'standalone'` |
|
|
| Sprache | TypeScript strict | |
|
|
| UI | Tailwind CSS 4, shadcn/ui (radix-nova), lucide | mobile first |
|
|
| Datenbank | SQLite via better-sqlite3, WAL | Datei unter `DATA_DIR/db` |
|
|
| ORM | Drizzle ORM + drizzle-kit | Migrationen in `drizzle/` |
|
|
| Auth | better-auth + `@better-auth/passkey` | E-Mail/Passwort und WebAuthn |
|
|
| Validierung | zod (serverseitig, immer) | |
|
|
| Bilder | sharp (nur serverseitig, Thumbnails) | |
|
|
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | |
|
|
| PDF | pdfjs-dist, lokal gebuendelt | kein CDN |
|
|
| Barcode | @zxing/browser, lazy geladen | nur beim Scannen |
|
|
| Archive | fflate | Export/Import als ZIP |
|
|
| Paketmanager | pnpm 10 | |
|
|
|
|
Bewusst **kein** Prisma (Standalone-Build im Container), **keine**
|
|
Cloud-Dienste, **keine** externen Requests aus dem Browser ausser zu
|
|
Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
|
|
|
## Konventionen
|
|
|
|
- **UI-Texte auf Deutsch**, **Code und Kommentare auf Englisch**.
|
|
- Datum `dd.MM.yyyy`, Waehrung EUR, Zeitzone `Europe/Berlin`.
|
|
- **Geldbetraege liegen als Integer in Cent** in der Datenbank
|
|
(`retail_price_cents`, `acquired_price_cents`). SQLite hat keinen
|
|
Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich
|
|
in `src/lib/money.ts`.
|
|
- Alle Datenbankspalten in `snake_case`. Auch in den Auth-Tabellen -
|
|
der Drizzle-Adapter von better-auth loest Modelle ueber die
|
|
TypeScript-Property-Namen auf, nicht ueber die Spaltennamen.
|
|
- IDs sind Text-UUIDs (`crypto.randomUUID()`), Zeitstempel sind
|
|
`integer(mode: 'timestamp')`.
|
|
- Routen sind deutsch (`/katalog`, `/sammlung`, `/wunschlisten`),
|
|
API-Routen englisch (`/api/health`, `/api/files`).
|
|
- Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
|
|
erste Zeile <= 72 Zeichen.
|
|
|
|
## Wichtige Module
|
|
|
|
| Datei | Zustaendig fuer |
|
|
|---|---|
|
|
| `src/lib/ownership.ts` | die eine Definition von "hat er" / "hat er nicht" |
|
|
| `src/lib/wishlist-logic.ts` | Mehrfachauswahl, Dubletten, Prioritaeten (rein, testbar) |
|
|
| `src/lib/tokens.ts` | Share-, Storno- und Einladungs-Token |
|
|
| `src/lib/invites.ts` | Einladungen inklusive der Freigabe fuer die Registrierung |
|
|
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
|
|
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
|
|
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
|
|
| `src/lib/similarity.ts` | Namensvergleich fuer die Dublettenpruefung (rein) |
|
|
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
|
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
|
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
|
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
|
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
|
|
|
## Befehle
|
|
|
|
```bash
|
|
pnpm dev # Entwicklungsserver auf http://localhost:3000
|
|
pnpm build # Produktionsbuild (standalone)
|
|
pnpm start # Produktionsserver
|
|
pnpm lint # ESLint
|
|
pnpm typecheck # tsc --noEmit
|
|
pnpm db:generate # SQL-Migration aus dem Schema erzeugen
|
|
pnpm db:migrate # Migrationen anwenden (auch im Container-Start)
|
|
pnpm db:seed # Beispieldaten einspielen
|
|
pnpm db:studio # Drizzle Studio
|
|
pnpm test # Vitest
|
|
pnpm test:e2e # Playwright
|
|
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen
|
|
|
|
node scripts/generate-icons.mjs # PWA-Icons aus der SVG-Quelle rendern
|
|
```
|
|
|
|
### Testkonventionen
|
|
|
|
- Unit-Tests liegen in `tests/unit`, laufen unter Node und duerfen auch
|
|
Server-Module laden: `server-only` ist in `vitest.config.mts` auf einen
|
|
leeren Stub gemappt.
|
|
- `tests/setup-env.ts` setzt Platzhalter fuer die Umgebung. `BASE_URL` wird
|
|
dort **immer** ueberschrieben, weil Vite denselben Namen fuer seinen
|
|
Public-Base-Path benutzt und ihn auf `/` setzt.
|
|
- Der Playwright-Smoke-Test nutzt `./data-e2e`, der Screenshot-Lauf
|
|
`./data-shots` gegen das Produktions-Image. Beide Verzeichnisse werden vor
|
|
jedem Lauf geleert und liegen nie im Git.
|
|
|
|
## Datenmodell
|
|
|
|
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
|
|
unabhaengig davon ob wir es besitzen. Ein **`collection_item`**
|
|
verknuepft ein Set mit dem *tatsaechlichen Besitz*.
|
|
|
|
> **"Hat er nicht"** = Sets im Katalog, zu denen kein `collection_item`
|
|
> mit einem Status ungleich `sold` existiert.
|
|
|
|
Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
|
|
|
|
### Katalog
|
|
|
|
| Tabelle | Zweck | Wichtige Felder |
|
|
|---|---|---|
|
|
| `manufacturer` | Hersteller | `name`, `slug` (unique), `website` |
|
|
| `theme` | Thema/Serie, hierarchisch | `manufacturer_id`, `parent_theme_id`, unique je (Hersteller, Name) |
|
|
| `set` | Katalogeintrag | unique `(manufacturer_id, set_number)`, `year`, `piece_count`, `minifig_count`, `ean`, `image_url` oder `image_path`, `retail_price_cents`, `external_ids` (JSON) |
|
|
| `tag`, `set_tag` | freie Verschlagwortung (n:m) | |
|
|
| `instruction` | Anleitung | `kind` = `pdf_upload` (dann `file_path`) oder `external_url` (dann `url`), per CHECK erzwungen |
|
|
|
|
### Besitz
|
|
|
|
Mehrere **Sammler** teilen sich den Katalog, nicht den Besitz: `collector`
|
|
haelt Name und Farbe, `collection_item.collector_id` und
|
|
`wishlist.collector_id` ordnen zu. Solange nur ein Sammler existiert, blendet
|
|
die Oberflaeche das Thema vollstaendig aus - Umschalter, Abfragen und Badges
|
|
erscheinen erst ab dem zweiten.
|
|
|
|
| Tabelle | Zweck | Wichtige Felder |
|
|
|---|---|---|
|
|
| `collector` | ein Kind | `name`, `color`, `birthday`, `sort_order` |
|
|
| `price_point` | beobachteter Preis je Set | `price_cents`, `condition`, `observed_at`, `source` |
|
|
| `collection_item` | ein besessenes Exemplar | `status` = `owned` / `built` / `disassembled` / `lent_out` / `sold`, `completeness`, `condition`, `location_id`, `acquired_*`, `gifted_by` |
|
|
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
|
|
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
|
|
| `photo` | eigene Fotos | gehoert entweder zu `set_id` **oder** `collection_item_id` (CHECK) |
|
|
|
|
### Wunschlisten
|
|
|
|
| Tabelle | Zweck | Wichtige Felder |
|
|
|---|---|---|
|
|
| `wishlist` | eine Liste | `share_token` (32 Zufallsbytes, unique), `is_public`, `surprise_mode`, `occasion`, `event_date`, `note` |
|
|
| `wishlist_item` | ein Wunsch | unique `(wishlist_id, set_id)`, `priority` 1-3 (CHECK), `sort_order`, `reserved_by_name`, `reserved_at`, `reservation_token_hash`, `fulfilled_at` |
|
|
|
|
Der Unique-Index auf `(wishlist_id, set_id)` macht das Ueberspringen von
|
|
Dubletten bei der Mehrfachauswahl zu einer **Datenbankgarantie**, nicht
|
|
nur zu einer UI-Regel: Der Bulk-Insert laeuft mit
|
|
`onConflictDoNothing()` und meldet die uebersprungenen Sets zurueck.
|
|
|
|
`fulfilled_at` wird gesetzt, sobald das Set in der Sammlung auftaucht.
|
|
|
|
### Auth
|
|
|
|
`user` (mit `role` = `admin` | `member`), `session`, `account`,
|
|
`verification`, `passkey`, `rateLimit` kommen von better-auth.
|
|
`invite` ist selbst gebaut: Der Klartext-Token steht nur im Link, in der
|
|
Datenbank liegt sein SHA-256-Hash. Gleiches gilt fuer
|
|
`wishlist_item.reservation_token_hash`. Der `wishlist.share_token`
|
|
bleibt im Klartext, weil er im Dashboard erneut angezeigt werden muss.
|
|
|
|
## Verzeichnisse zur Laufzeit
|
|
|
|
Ein einziges `./data` reicht fuer ein vollstaendiges Backup.
|
|
|
|
```
|
|
DATA_DIR/
|
|
db/bricktrack.db SQLite (+ -wal, -shm)
|
|
files/
|
|
images/sets/<setId>/... Set-Bilder (oeffentlich lesbar)
|
|
photos/... eigene Fotos (nur mit Session)
|
|
instructions/<setId>/... Anleitungs-PDFs (nur mit Session)
|
|
```
|
|
|
|
Set-**Bilder** sind ohne Login abrufbar, weil die oeffentliche
|
|
Wunschliste sie braucht. Fotos und Anleitungen erfordern zwingend eine
|
|
Session.
|
|
|
|
## Sicherheitsregeln
|
|
|
|
1. Jede Eingabe wird **serverseitig** mit zod validiert. Clientseitige
|
|
Validierung ist Komfort, nie Schutz.
|
|
2. Uploads: nur PDF, JPEG, PNG, WebP. Pruefung ueber MIME **und**
|
|
Magic Bytes. Groessenlimit aus `MAX_UPLOAD_MB`. Dateinamen werden
|
|
**immer** serverseitig erzeugt, nie vom Client uebernommen.
|
|
3. Oeffentliche Wunschlisten-Routen (`/w/[shareToken]`) nutzen
|
|
ausschliesslich den Share-Token als Zugriffsschluessel und geben
|
|
nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen,
|
|
keine Sammlungsdaten, keine IDs anderer Objekte.
|
|
4. Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich
|
|
serverseitig.
|
|
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
|
Rate-Limiting auf den Login-Endpunkten.
|
|
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
|
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der
|
|
Export.
|
|
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
|
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
|
Cache geleert.
|
|
|
|
## Phasenplan
|
|
|
|
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung,
|
|
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. *Fertig in 0.1.0.*
|
|
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
|
|
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
|
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
|
E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
|
|
|
|
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
|
|
|
|
## Arbeitsweise
|
|
|
|
- Nie direkt auf `main` entwickeln; pro Phase ein Branch
|
|
(`feat/phase-1`), Merge erst nach manuellem Test.
|
|
- Nach jedem abgeschlossenen Teilschritt committen.
|
|
- `pnpm lint` und `pnpm typecheck` muessen sauber sein, bevor eine Phase
|
|
als fertig gilt.
|