diff --git a/.env.example b/.env.example
index 75817ac..4369b77 100644
--- a/.env.example
+++ b/.env.example
@@ -32,3 +32,16 @@ TZ=Europe/Berlin
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
# REBRICKABLE_API_KEY=
+
+# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne
+# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere
+# funktioniert unveraendert.
+# SMTP_HOST=mail.example.de
+# SMTP_PORT=587
+# SMTP_SECURE=false
+# SMTP_USER=bricktrack@example.de
+# SMTP_PASSWORD=
+# SMTP_FROM=Bricktrack
+
+# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
+# REMINDER_DAYS_BEFORE=14
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cbb832..0b8a7ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,53 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
+## [0.3.0] - 2026-09-07
+
+Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
+doppelten Katalogeintraegen.
+
+### Hinzugefuegt
+
+**Mehrere Sammler**
+- Der Katalog bleibt gemeinsam, Sammlung und Wunschlisten gehoeren je einem
+ Sammler. "Hat er das schon?" beantwortet damit die Frage fuer *ihn*: ein Set,
+ das nur die Schwester besitzt, faellt fuer den Bruder unter "fehlt noch".
+- Umschalter in Katalog und Sammlung, Farbmarkierung je Sammler, Zuordnung im
+ Sammlungseintrag und je Wunschliste.
+- Solange es nur einen Sammler gibt, ist davon nichts zu sehen - die App
+ bleibt genauso einfach wie vorher.
+
+**Preisbeobachtung**
+- Notierte Preise je Set mit Betrag, Zustand, Datum, Quelle und Notiz.
+- Wertentwicklung gegen die UVP, in der Sammlung als Kachel "Aktueller Wert".
+
+**Erinnerungen**
+- E-Mail an den Besitzer, wenn der Termin einer Wunschliste naeher rueckt und
+ noch Wuensche offen sind - standardmaessig 14 Tage vorher, hoechstens einmal
+ pro Woche je Liste.
+- Verwaltungsseite mit Verbindungstest, Testnachricht und sofortiger Pruefung.
+- Ohne SMTP-Konfiguration bleibt die Funktion vollstaendig aus.
+
+**Dublettenpruefung**
+- Vor dem Anlegen eines Sets warnt die App vor gleicher Setnummer beim
+ gleichen Hersteller, sehr aehnlichem Namen und gleicher Nummer bei einem
+ anderen Hersteller - mit Link zum Verdaechtigen und der Moeglichkeit,
+ trotzdem anzulegen.
+
+### Geaendert
+
+- Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alle
+ vorhandenen Sammlungseintraege und Wunschlisten zu. Umbenennen unter
+ Verwaltung -> Sammler.
+- Der Smoke-Test laeuft gegen das Docker-Image statt gegen den Dev-Server.
+
+### Nicht umgesetzt
+
+- Der BrickLink-Preisfuehrer war im Plan als optional vermerkt und wurde
+ bewusst weggelassen: OAuth1 mit einer pro Nutzer registrierten Anwendung ist
+ viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Das
+ Feld "Wo" nimmt jede Quelle auf, die Datenstruktur bleibt offen.
+
## [0.2.0] - 2026-09-07
Phase 2: die Handgriffe, die den Alltag schneller machen.
@@ -147,6 +194,7 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
Docker-Build unter Linux ist davon nicht betroffen.
-[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.2.0...HEAD
+[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.3.0...HEAD
+[0.3.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
diff --git a/CLAUDE.md b/CLAUDE.md
index 142e680..9071836 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,6 +57,10 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
| `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 |
@@ -114,8 +118,16 @@ Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
### 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` |
@@ -190,7 +202,7 @@ Session.
- **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.
+ E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
diff --git a/README.md b/README.md
index 91bc6a7..fbc6cff 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,9 @@ du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
| **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 |
+| **Mehrere Kinder** | Gemeinsamer Katalog, getrennte Sammlungen und Wunschlisten – sichtbar erst ab dem zweiten Sammler |
+| **Preise** | Notierte Preise je Set, Wertentwicklung gegen die UVP |
+| **Erinnerungen** | E-Mail, wenn ein Termin näher rückt und noch Wünsche offen sind (SMTP optional) |
| **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
@@ -81,7 +84,9 @@ Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. |
| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. |
| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
-| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. |
+| `REBRICKABLE_API_KEY` | nein | – | Lego-Import. Ohne Key läuft alles manuell. |
+| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` | nein | – | Mailversand für Wunschlisten-Erinnerungen. Ohne `SMTP_HOST` und `SMTP_FROM` ist die Funktion aus. |
+| `REMINDER_DAYS_BEFORE` | nein | `14` | Wie viele Tage vor dem Termin erinnert wird. |
### Reverse Proxy
@@ -208,6 +213,10 @@ 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`.
+Gibt es mehrere **Sammler**, bekommt die Frage ein „für wen": Der Katalog ist
+gemeinsam, der Besitz nicht. Ein Set, das nur das Geschwisterkind hat, fehlt
+dem anderen weiterhin.
+
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
## Sicherheit
diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md
index 71a73c8..b5304a8 100644
--- a/docs/ANLEITUNG.md
+++ b/docs/ANLEITUNG.md
@@ -27,7 +27,11 @@ Kinderzimmer, nicht für den Schreibtisch.
13. [Noch ein Konto für die Familie](#13-einladungen)
14. [Daten sichern, umziehen, auswerten](#14-daten)
15. [Auf den Startbildschirm legen](#15-startbildschirm)
-16. [Antworten auf die zwei wichtigen Fragen](#16-die-zwei-fragen)
+16. [Zwei Kinder, ein Katalog](#16-mehrere-sammler)
+17. [Was ein Set heute wert ist](#17-preise)
+18. [Erinnerung vor dem Termin](#18-erinnerungen)
+19. [Schutz vor doppelten Einträgen](#19-dubletten)
+20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen)
---
@@ -419,7 +423,115 @@ den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
---
-## 16. Die zwei Fragen
+## 16. Mehrere Sammler
+
+
+
+Kommt ein zweites Kind dazu, wird aus „hat er das schon?" die Frage „hat
+**er** das schon?". Genau dafür gibt es Sammler.
+
+Der **Katalog bleibt gemeinsam**: Ein Set existiert einmal, unabhängig davon,
+wer es besitzt. **Sammlung und Wunschlisten** gehören dagegen je einem
+Sammler. Ein Set, das nur die Schwester hat, fällt für den Bruder wieder unter
+„fehlt noch" – und darf auf seine Wunschliste.
+
+> **Solange es nur einen Sammler gibt, siehst du davon nichts.** Kein
+> Umschalter, keine Abfrage, keine Farben. Die App bleibt genauso einfach wie
+> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
+> es auch.
+
+Anlegen unter **Verwaltung → Sammler**. Jeder bekommt einen Namen und eine
+Farbe zur Unterscheidung.
+
+
+
+Danach steht in Katalog und Sammlung oben ein Umschalter. „Alle zusammen"
+zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – inklusive
+der Frage, was **diesem** Kind noch fehlt.
+
+Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und
+beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
+als „hat er schon" zählt und wann ein Wunsch automatisch als erfüllt gilt.
+
+> Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
+> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
+> Umbenennen unter **Verwaltung → Sammler**, fertig.
+
+---
+
+## 17. Preise
+
+
+
+Auf jeder Set-Seite gibt es **„Preis notieren"**: Betrag, Zustand (neu oder
+gebraucht), wann und wo gesehen. Daraus wird eine Reihe – und damit eine
+Antwort auf die eigentlich interessante Frage: Was hat das Set seit dem Kauf
+gemacht?
+
+- **Ursprünglich** ist die UVP des Sets, sonst der älteste notierte Preis.
+- **Aktuell** ist der neueste notierte Preis.
+- **Entwicklung** ist die Differenz in Prozent.
+
+In der Sammlung wird die Kachel „Wert nach UVP" automatisch zu **„Aktueller
+Wert"**, sobald irgendwo ein Preis notiert ist.
+
+> Es wird **kein** Preisdienst abgefragt. Was hier steht, hast du selbst
+> eingetragen – das ist ehrlicher als eine Zahl, deren Herkunft niemand kennt,
+> und es geht nichts nach draußen.
+
+---
+
+## 18. Erinnerungen
+
+
+
+Wenn ein Wunschlisten-Termin näher rückt und **noch Wünsche offen sind**,
+schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchstens
+einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
+Erinnerung ohne Handlungsbedarf ist nur Lärm.
+
+Dafür braucht es einen SMTP-Zugang in der `.env`:
+
+```bash
+SMTP_HOST=mail.example.de
+SMTP_PORT=587
+SMTP_USER=bricktrack@example.de
+SMTP_PASSWORD=…
+SMTP_FROM=Bricktrack
+
+# Optional, Standard ist 14:
+REMINDER_DAYS_BEFORE=14
+```
+
+Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine
+Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
+Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
+viel dort noch offen ist.
+
+Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert
+unverändert.
+
+---
+
+## 19. Dubletten
+
+Beim Anlegen eines neuen Sets schaut Bricktrack nach, ob es das schon gibt:
+
+- **gleiche Setnummer beim gleichen Hersteller** – dann ist es dasselbe Set,
+- **sehr ähnlicher Name** – meist ein Tippfehler oder eine andere Schreibweise,
+- **gleiche Nummer bei einem anderen Hersteller** – meist der falsche
+ Hersteller im Formular.
+
+Passt etwas, erscheint ein Hinweis mit Link zu den Verdächtigen. Von dort
+kommst du direkt zum vorhandenen Eintrag – oder legst mit „Trotzdem anlegen"
+weiter an, wenn es wirklich ein anderes Set ist.
+
+Die Erkennung ist bewusst zurückhaltend eingestellt: Tippfehler schlagen an,
+zwei echte Sets desselben Themas nicht.
+
+---
+
+## 20. Die zwei Fragen
Dafür ist die ganze App gebaut:
@@ -428,7 +540,8 @@ Dafür ist die ganze App gebaut:
**„Welche Sets hat er noch nicht?"**
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
-Wunschliste und per Link zu Oma.
+Wunschliste und per Link zu Oma. Bei mehreren Kindern beantwortet der
+Umschalter oben die Frage für jedes einzeln.
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
Haken dran, habt ihr es schon.
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index 60d4e5c..e9924d5 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -45,6 +45,20 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
**Summe Phase 2: 0:55**
+## Phase 3 – Nice to have (v0.3.0)
+
+### 07.09.2026
+
+| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
+|---|---|---|---|---|---|
+| 08:35 | 09:00 | 0:25 | Mehrere Sammler | Neue Tabelle, Migration mit Nachziehen der Bestandsdaten, Besitzlogik um eine Dimension erweitert, Umschalter in Katalog und Sammlung, Zuordnung in Sammlungseintrag und Wunschliste | `c23b754` |
+| 09:00 | 09:13 | 0:13 | Dublettenprüfung | Ähnlichkeitsvergleich mit konservativer Schwelle, Warndialog mit Links, E2E-Test | `87131b8` |
+| 09:13 | 09:17 | 0:04 | Preisbeobachtung | Preisreihe je Set, Wertentwicklung gegen die UVP, Kachel „Aktueller Wert" | `91fb66b` |
+| 09:17 | 09:22 | 0:05 | Erinnerungen | SMTP optional, stündliche Prüfung, Sperrfrist je Liste, Verwaltungsseite mit Testfunktionen | `0d08df0` |
+| 09:22 | 09:50 | 0:28 | Verifikation und Dokumentation | Docker-Build, Smoke-Test, Screenshots, Changelog 0.3.0, Anleitung | folgt |
+
+**Summe Phase 3: 1:15**
+
---
## Notizen und Entscheidungen
@@ -120,6 +134,21 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
+### Phase 3
+
+- **Migration mit Bestandsdaten**: Die generierte SQL-Datei wurde von Hand um
+ drei Anweisungen ergänzt, die einen Sammler anlegen und alles Vorhandene
+ zuordnen. Ohne das wären nach dem Update alle Sammlungseinträge ohne
+ Besitzer gewesen.
+- **Instrumentation und native Module**: Der Zeitplan für Erinnerungen liess
+ sich nicht aus Next' `instrumentation.ts` starten – die Datei wird auch für
+ die Edge-Runtime übersetzt, und das native SQLite-Modul bricht dort den
+ Build. Der Zeitplan startet jetzt aus dem Healthcheck heraus, den der
+ Container ohnehin alle 30 Sekunden aufruft.
+- **BrickLink bewusst weggelassen**: OAuth1 mit einer pro Nutzer registrierten
+ Anwendung ist viel Apparat für eine Zahl, die man in zehn Sekunden eintippt.
+ Im Plan war der Punkt als optional vermerkt.
+
### Beim Testen gefunden und behoben
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
@@ -130,3 +159,8 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
`required` des Eingabefeldes.
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
+- Die Sperrfrist der Erinnerungen rechnete mit umgedrehtem Vorzeichen und
+ hätte jede Erinnerung dauerhaft unterdrückt. Der Test dafür war zuerst da.
+- Der Screenshot-Lauf wählte plötzlich die falschen Kacheln aus: Der neue
+ Sammler-Umschalter benutzt ebenfalls `aria-pressed`. Die Auswahlkacheln
+ tragen jetzt ein eigenes `data-set-card`.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index fe01b00..fad9430 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -46,12 +46,25 @@ Laeuft vollstaendig im Docker-Stack.
Sammlung zusaetzlich als CSV
- [x] PWA: Manifest, Service Worker, Katalog offline lesbar
-## Phase 3 - Nice to have (Ziel: v0.3.0)
+## Phase 3 - Nice to have (v0.3.0, abgeschlossen am 07.09.2026)
-- [ ] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
+- [x] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
gemeinsamer Katalog
-- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt,
- optional BrickLink-Preisfuehrer)
-- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
+- [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
+ Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
+ bewusst weggelassen: er verlangt OAuth1 mit einer pro Nutzer
+ registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in
+ zehn Sekunden eintippen laesst. Die Datenstruktur ist offen dafuer.
+- [x] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
rueckt und Eintraege unreserviert sind
-- [ ] Dublettenpruefung beim Anlegen von Sets
+- [x] Dublettenpruefung beim Anlegen von Sets
+
+## Danach
+
+Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
+und ohne Anspruch auf Vollstaendigkeit:
+
+- [ ] BrickLink-Preisfuehrer anbinden, falls der Aufwand sich doch lohnt
+- [ ] Mehrere Fotos je Sammlungseintrag (bisher nur je Set)
+- [ ] Anleitungen im PDF-Viewer durchsuchen
+- [ ] Statistik ueber die Zeit: was kam wann dazu
diff --git a/docs/bilder/03-verwaltung-hersteller.png b/docs/bilder/03-verwaltung-hersteller.png
index e4db9b5..6ada860 100644
Binary files a/docs/bilder/03-verwaltung-hersteller.png and b/docs/bilder/03-verwaltung-hersteller.png differ
diff --git a/docs/bilder/04-katalog.png b/docs/bilder/04-katalog.png
index 77f9643..980c770 100644
Binary files a/docs/bilder/04-katalog.png and b/docs/bilder/04-katalog.png differ
diff --git a/docs/bilder/05-fehlende-sets.png b/docs/bilder/05-fehlende-sets.png
index 2a1bd7f..155fa39 100644
Binary files a/docs/bilder/05-fehlende-sets.png and b/docs/bilder/05-fehlende-sets.png differ
diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png
index eb08bc7..c3ef473 100644
Binary files a/docs/bilder/06-mehrfachauswahl.png and b/docs/bilder/06-mehrfachauswahl.png differ
diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png
index 3527da4..974026b 100644
Binary files a/docs/bilder/07-wunschliste-dialog.png and b/docs/bilder/07-wunschliste-dialog.png differ
diff --git a/docs/bilder/08-set-detail.png b/docs/bilder/08-set-detail.png
index dade78c..ad0f8c3 100644
Binary files a/docs/bilder/08-set-detail.png and b/docs/bilder/08-set-detail.png differ
diff --git a/docs/bilder/09-sammlung.png b/docs/bilder/09-sammlung.png
index 57ff976..87a8485 100644
Binary files a/docs/bilder/09-sammlung.png and b/docs/bilder/09-sammlung.png differ
diff --git a/docs/bilder/10-wunschlisten.png b/docs/bilder/10-wunschlisten.png
index b0b69fe..cb6e1c1 100644
Binary files a/docs/bilder/10-wunschlisten.png and b/docs/bilder/10-wunschlisten.png differ
diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png
index becf7c2..779a267 100644
Binary files a/docs/bilder/11-wunschliste-bearbeiten.png and b/docs/bilder/11-wunschliste-bearbeiten.png differ
diff --git a/docs/bilder/13-reservieren.png b/docs/bilder/13-reservieren.png
index 5b08544..080d9b0 100644
Binary files a/docs/bilder/13-reservieren.png and b/docs/bilder/13-reservieren.png differ
diff --git a/docs/bilder/14-reserviert.png b/docs/bilder/14-reserviert.png
index e9ec42c..8141b64 100644
Binary files a/docs/bilder/14-reserviert.png and b/docs/bilder/14-reserviert.png differ
diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png
index f406a05..e19f81e 100644
Binary files a/docs/bilder/15-wunschliste-reserviert.png and b/docs/bilder/15-wunschliste-reserviert.png differ
diff --git a/docs/bilder/18-fehlteile.png b/docs/bilder/18-fehlteile.png
index a857431..8a0597e 100644
Binary files a/docs/bilder/18-fehlteile.png and b/docs/bilder/18-fehlteile.png differ
diff --git a/docs/bilder/19-einladungen.png b/docs/bilder/19-einladungen.png
index fb4e304..5413fca 100644
Binary files a/docs/bilder/19-einladungen.png and b/docs/bilder/19-einladungen.png differ
diff --git a/docs/bilder/20-daten.png b/docs/bilder/20-daten.png
index 6d51743..494b872 100644
Binary files a/docs/bilder/20-daten.png and b/docs/bilder/20-daten.png differ
diff --git a/docs/bilder/21-sammler.png b/docs/bilder/21-sammler.png
new file mode 100644
index 0000000..55db779
Binary files /dev/null and b/docs/bilder/21-sammler.png differ
diff --git a/docs/bilder/22-sammler-filter.png b/docs/bilder/22-sammler-filter.png
new file mode 100644
index 0000000..3d906b6
Binary files /dev/null and b/docs/bilder/22-sammler-filter.png differ
diff --git a/docs/bilder/23-preise.png b/docs/bilder/23-preise.png
new file mode 100644
index 0000000..b221209
Binary files /dev/null and b/docs/bilder/23-preise.png differ
diff --git a/docs/bilder/24-erinnerungen.png b/docs/bilder/24-erinnerungen.png
new file mode 100644
index 0000000..5145391
Binary files /dev/null and b/docs/bilder/24-erinnerungen.png differ
diff --git a/drizzle/0001_lumpy_bill_hollister.sql b/drizzle/0001_lumpy_bill_hollister.sql
new file mode 100644
index 0000000..e271c15
--- /dev/null
+++ b/drizzle/0001_lumpy_bill_hollister.sql
@@ -0,0 +1,28 @@
+CREATE TABLE `collector` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `color` text DEFAULT 'blue' NOT NULL,
+ `birthday` integer,
+ `notes` text,
+ `sort_order` integer DEFAULT 0 NOT NULL,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
+ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
+CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
+ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
+-- Von Hand ergaenzt: Bestandsdaten nachziehen.
+-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
+-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
+-- Verwaltung -> Sammler.
+INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
+SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
+WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
+UPDATE `collection_item`
+SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
+WHERE `collector_id` IS NULL;--> statement-breakpoint
+UPDATE `wishlist`
+SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
+WHERE `collector_id` IS NULL;
diff --git a/drizzle/0002_aspiring_whiplash.sql b/drizzle/0002_aspiring_whiplash.sql
new file mode 100644
index 0000000..f5ad919
--- /dev/null
+++ b/drizzle/0002_aspiring_whiplash.sql
@@ -0,0 +1,14 @@
+CREATE TABLE `price_point` (
+ `id` text PRIMARY KEY NOT NULL,
+ `set_id` text NOT NULL,
+ `price_cents` integer NOT NULL,
+ `condition` text DEFAULT 'new' NOT NULL,
+ `source` text,
+ `observed_at` integer NOT NULL,
+ `note` text,
+ `created_at` integer NOT NULL,
+ FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade,
+ CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0)
+);
+--> statement-breakpoint
+CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`);
\ No newline at end of file
diff --git a/drizzle/0003_powerful_sally_floyd.sql b/drizzle/0003_powerful_sally_floyd.sql
new file mode 100644
index 0000000..6ef4793
--- /dev/null
+++ b/drizzle/0003_powerful_sally_floyd.sql
@@ -0,0 +1 @@
+ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..849fc1a
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,1992 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "db83b244-734e-48d4-9656-f335650ba9c7",
+ "prevId": "426c9bf5-ccbd-473a-a819-11f8b9252494",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_user_idx": {
+ "name": "account_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_by_user_id": {
+ "name": "used_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_token_hash_unique": {
+ "name": "invite_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_created_by_user_id_user_id_fk": {
+ "name": "invite_created_by_user_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invite_used_by_user_id_user_id_fk": {
+ "name": "invite_used_by_user_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "used_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passkey": {
+ "name": "passkey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passkey_credential_id_unique": {
+ "name": "passkey_credential_id_unique",
+ "columns": [
+ "credential_id"
+ ],
+ "isUnique": true
+ },
+ "passkey_user_idx": {
+ "name": "passkey_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rate_limit": {
+ "name": "rate_limit",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_user_idx": {
+ "name": "session_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "instruction": {
+ "name": "instruction",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_count": {
+ "name": "page_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "instruction_set_idx": {
+ "name": "instruction_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "instruction_set_id_set_id_fk": {
+ "name": "instruction_set_id_set_id_fk",
+ "tableFrom": "instruction",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "instruction_source": {
+ "name": "instruction_source",
+ "value": "(\"instruction\".\"kind\" = 'pdf_upload' AND \"instruction\".\"file_path\" IS NOT NULL AND \"instruction\".\"url\" IS NULL)\n OR (\"instruction\".\"kind\" = 'external_url' AND \"instruction\".\"url\" IS NOT NULL AND \"instruction\".\"file_path\" IS NULL)"
+ }
+ }
+ },
+ "manufacturer": {
+ "name": "manufacturer",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "manufacturer_slug_unique": {
+ "name": "manufacturer_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "set": {
+ "name": "set",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manufacturer_id": {
+ "name": "manufacturer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_id": {
+ "name": "theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "set_number": {
+ "name": "set_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "year": {
+ "name": "year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "piece_count": {
+ "name": "piece_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "minifig_count": {
+ "name": "minifig_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ean": {
+ "name": "ean",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "age_range": {
+ "name": "age_range",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_path": {
+ "name": "image_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retail_price_cents": {
+ "name": "retail_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_ids": {
+ "name": "external_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "set_manufacturer_number_unique": {
+ "name": "set_manufacturer_number_unique",
+ "columns": [
+ "manufacturer_id",
+ "set_number"
+ ],
+ "isUnique": true
+ },
+ "set_name_idx": {
+ "name": "set_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "set_ean_idx": {
+ "name": "set_ean_idx",
+ "columns": [
+ "ean"
+ ],
+ "isUnique": false
+ },
+ "set_theme_idx": {
+ "name": "set_theme_idx",
+ "columns": [
+ "theme_id"
+ ],
+ "isUnique": false
+ },
+ "set_year_idx": {
+ "name": "set_year_idx",
+ "columns": [
+ "year"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "set_manufacturer_id_manufacturer_id_fk": {
+ "name": "set_manufacturer_id_manufacturer_id_fk",
+ "tableFrom": "set",
+ "tableTo": "manufacturer",
+ "columnsFrom": [
+ "manufacturer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "set_theme_id_theme_id_fk": {
+ "name": "set_theme_id_theme_id_fk",
+ "tableFrom": "set",
+ "tableTo": "theme",
+ "columnsFrom": [
+ "theme_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "set_tag": {
+ "name": "set_tag",
+ "columns": {
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "set_tag_set_id_set_id_fk": {
+ "name": "set_tag_set_id_set_id_fk",
+ "tableFrom": "set_tag",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "set_tag_tag_id_tag_id_fk": {
+ "name": "set_tag_tag_id_tag_id_fk",
+ "tableFrom": "set_tag",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "set_tag_set_id_tag_id_pk": {
+ "columns": [
+ "set_id",
+ "tag_id"
+ ],
+ "name": "set_tag_set_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tag": {
+ "name": "tag",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tag_slug_unique": {
+ "name": "tag_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "theme": {
+ "name": "theme",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manufacturer_id": {
+ "name": "manufacturer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_theme_id": {
+ "name": "parent_theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "theme_manufacturer_idx": {
+ "name": "theme_manufacturer_idx",
+ "columns": [
+ "manufacturer_id"
+ ],
+ "isUnique": false
+ },
+ "theme_unique_name": {
+ "name": "theme_unique_name",
+ "columns": [
+ "manufacturer_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "theme_manufacturer_id_manufacturer_id_fk": {
+ "name": "theme_manufacturer_id_manufacturer_id_fk",
+ "tableFrom": "theme",
+ "tableTo": "manufacturer",
+ "columnsFrom": [
+ "manufacturer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "theme_parent_theme_id_theme_id_fk": {
+ "name": "theme_parent_theme_id_theme_id_fk",
+ "tableFrom": "theme",
+ "tableTo": "theme",
+ "columnsFrom": [
+ "parent_theme_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "collector": {
+ "name": "collector",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'blue'"
+ },
+ "birthday": {
+ "name": "birthday",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "collector_sort_idx": {
+ "name": "collector_sort_idx",
+ "columns": [
+ "sort_order",
+ "name"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "collection_item": {
+ "name": "collection_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collector_id": {
+ "name": "collector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'owned'"
+ },
+ "completeness": {
+ "name": "completeness",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_id": {
+ "name": "location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_at": {
+ "name": "acquired_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_price_cents": {
+ "name": "acquired_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_from": {
+ "name": "acquired_from",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gifted_by": {
+ "name": "gifted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lent_out_to": {
+ "name": "lent_out_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "collection_item_set_idx": {
+ "name": "collection_item_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ },
+ "collection_item_collector_idx": {
+ "name": "collection_item_collector_idx",
+ "columns": [
+ "collector_id"
+ ],
+ "isUnique": false
+ },
+ "collection_item_status_idx": {
+ "name": "collection_item_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "collection_item_location_idx": {
+ "name": "collection_item_location_idx",
+ "columns": [
+ "location_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "collection_item_set_id_set_id_fk": {
+ "name": "collection_item_set_id_set_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_item_collector_id_collector_id_fk": {
+ "name": "collection_item_collector_id_collector_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "collector",
+ "columnsFrom": [
+ "collector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_item_location_id_location_id_fk": {
+ "name": "collection_item_location_id_location_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "location",
+ "columnsFrom": [
+ "location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "location": {
+ "name": "location",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_location_id": {
+ "name": "parent_location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "location_parent_location_id_location_id_fk": {
+ "name": "location_parent_location_id_location_id_fk",
+ "tableFrom": "location",
+ "tableTo": "location",
+ "columnsFrom": [
+ "parent_location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "missing_part": {
+ "name": "missing_part",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collection_item_id": {
+ "name": "collection_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "part_number": {
+ "name": "part_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "color_name": {
+ "name": "color_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "resolved": {
+ "name": "resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "missing_part_item_idx": {
+ "name": "missing_part_item_idx",
+ "columns": [
+ "collection_item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "missing_part_collection_item_id_collection_item_id_fk": {
+ "name": "missing_part_collection_item_id_collection_item_id_fk",
+ "tableFrom": "missing_part",
+ "tableTo": "collection_item",
+ "columnsFrom": [
+ "collection_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "photo": {
+ "name": "photo",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "collection_item_id": {
+ "name": "collection_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbnail_path": {
+ "name": "thumbnail_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "photo_set_idx": {
+ "name": "photo_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ },
+ "photo_item_idx": {
+ "name": "photo_item_idx",
+ "columns": [
+ "collection_item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "photo_set_id_set_id_fk": {
+ "name": "photo_set_id_set_id_fk",
+ "tableFrom": "photo",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "photo_collection_item_id_collection_item_id_fk": {
+ "name": "photo_collection_item_id_collection_item_id_fk",
+ "tableFrom": "photo",
+ "tableTo": "collection_item",
+ "columnsFrom": [
+ "collection_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "photo_single_owner": {
+ "name": "photo_single_owner",
+ "value": "(\"photo\".\"set_id\" IS NOT NULL) <> (\"photo\".\"collection_item_id\" IS NOT NULL)"
+ }
+ }
+ },
+ "wishlist": {
+ "name": "wishlist",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collector_id": {
+ "name": "collector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "occasion": {
+ "name": "occasion",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'other'"
+ },
+ "event_date": {
+ "name": "event_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "surprise_mode": {
+ "name": "surprise_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "wishlist_share_token_unique": {
+ "name": "wishlist_share_token_unique",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ },
+ "wishlist_owner_idx": {
+ "name": "wishlist_owner_idx",
+ "columns": [
+ "owner_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "wishlist_owner_user_id_user_id_fk": {
+ "name": "wishlist_owner_user_id_user_id_fk",
+ "tableFrom": "wishlist",
+ "tableTo": "user",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wishlist_collector_id_collector_id_fk": {
+ "name": "wishlist_collector_id_collector_id_fk",
+ "tableFrom": "wishlist",
+ "tableTo": "collector",
+ "columnsFrom": [
+ "collector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "wishlist_item": {
+ "name": "wishlist_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "wishlist_id": {
+ "name": "wishlist_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reserved_by_name": {
+ "name": "reserved_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reserved_at": {
+ "name": "reserved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reservation_token_hash": {
+ "name": "reservation_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fulfilled_at": {
+ "name": "fulfilled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "wishlist_item_unique": {
+ "name": "wishlist_item_unique",
+ "columns": [
+ "wishlist_id",
+ "set_id"
+ ],
+ "isUnique": true
+ },
+ "wishlist_item_list_idx": {
+ "name": "wishlist_item_list_idx",
+ "columns": [
+ "wishlist_id",
+ "sort_order"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "wishlist_item_wishlist_id_wishlist_id_fk": {
+ "name": "wishlist_item_wishlist_id_wishlist_id_fk",
+ "tableFrom": "wishlist_item",
+ "tableTo": "wishlist",
+ "columnsFrom": [
+ "wishlist_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wishlist_item_set_id_set_id_fk": {
+ "name": "wishlist_item_set_id_set_id_fk",
+ "tableFrom": "wishlist_item",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "wishlist_item_priority": {
+ "name": "wishlist_item_priority",
+ "value": "\"wishlist_item\".\"priority\" BETWEEN 1 AND 3"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..cbc9ee4
--- /dev/null
+++ b/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,2087 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "36e88ca3-098f-40f0-9253-abe30ff27c47",
+ "prevId": "db83b244-734e-48d4-9656-f335650ba9c7",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_user_idx": {
+ "name": "account_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_by_user_id": {
+ "name": "used_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_token_hash_unique": {
+ "name": "invite_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_created_by_user_id_user_id_fk": {
+ "name": "invite_created_by_user_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invite_used_by_user_id_user_id_fk": {
+ "name": "invite_used_by_user_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "used_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passkey": {
+ "name": "passkey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passkey_credential_id_unique": {
+ "name": "passkey_credential_id_unique",
+ "columns": [
+ "credential_id"
+ ],
+ "isUnique": true
+ },
+ "passkey_user_idx": {
+ "name": "passkey_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rate_limit": {
+ "name": "rate_limit",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_user_idx": {
+ "name": "session_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "instruction": {
+ "name": "instruction",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_count": {
+ "name": "page_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "instruction_set_idx": {
+ "name": "instruction_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "instruction_set_id_set_id_fk": {
+ "name": "instruction_set_id_set_id_fk",
+ "tableFrom": "instruction",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "instruction_source": {
+ "name": "instruction_source",
+ "value": "(\"instruction\".\"kind\" = 'pdf_upload' AND \"instruction\".\"file_path\" IS NOT NULL AND \"instruction\".\"url\" IS NULL)\n OR (\"instruction\".\"kind\" = 'external_url' AND \"instruction\".\"url\" IS NOT NULL AND \"instruction\".\"file_path\" IS NULL)"
+ }
+ }
+ },
+ "manufacturer": {
+ "name": "manufacturer",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "manufacturer_slug_unique": {
+ "name": "manufacturer_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "set": {
+ "name": "set",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manufacturer_id": {
+ "name": "manufacturer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_id": {
+ "name": "theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "set_number": {
+ "name": "set_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "year": {
+ "name": "year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "piece_count": {
+ "name": "piece_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "minifig_count": {
+ "name": "minifig_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ean": {
+ "name": "ean",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "age_range": {
+ "name": "age_range",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_path": {
+ "name": "image_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retail_price_cents": {
+ "name": "retail_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_ids": {
+ "name": "external_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "set_manufacturer_number_unique": {
+ "name": "set_manufacturer_number_unique",
+ "columns": [
+ "manufacturer_id",
+ "set_number"
+ ],
+ "isUnique": true
+ },
+ "set_name_idx": {
+ "name": "set_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "set_ean_idx": {
+ "name": "set_ean_idx",
+ "columns": [
+ "ean"
+ ],
+ "isUnique": false
+ },
+ "set_theme_idx": {
+ "name": "set_theme_idx",
+ "columns": [
+ "theme_id"
+ ],
+ "isUnique": false
+ },
+ "set_year_idx": {
+ "name": "set_year_idx",
+ "columns": [
+ "year"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "set_manufacturer_id_manufacturer_id_fk": {
+ "name": "set_manufacturer_id_manufacturer_id_fk",
+ "tableFrom": "set",
+ "tableTo": "manufacturer",
+ "columnsFrom": [
+ "manufacturer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "set_theme_id_theme_id_fk": {
+ "name": "set_theme_id_theme_id_fk",
+ "tableFrom": "set",
+ "tableTo": "theme",
+ "columnsFrom": [
+ "theme_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "set_tag": {
+ "name": "set_tag",
+ "columns": {
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "set_tag_set_id_set_id_fk": {
+ "name": "set_tag_set_id_set_id_fk",
+ "tableFrom": "set_tag",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "set_tag_tag_id_tag_id_fk": {
+ "name": "set_tag_tag_id_tag_id_fk",
+ "tableFrom": "set_tag",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "set_tag_set_id_tag_id_pk": {
+ "columns": [
+ "set_id",
+ "tag_id"
+ ],
+ "name": "set_tag_set_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tag": {
+ "name": "tag",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tag_slug_unique": {
+ "name": "tag_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "theme": {
+ "name": "theme",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manufacturer_id": {
+ "name": "manufacturer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_theme_id": {
+ "name": "parent_theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "theme_manufacturer_idx": {
+ "name": "theme_manufacturer_idx",
+ "columns": [
+ "manufacturer_id"
+ ],
+ "isUnique": false
+ },
+ "theme_unique_name": {
+ "name": "theme_unique_name",
+ "columns": [
+ "manufacturer_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "theme_manufacturer_id_manufacturer_id_fk": {
+ "name": "theme_manufacturer_id_manufacturer_id_fk",
+ "tableFrom": "theme",
+ "tableTo": "manufacturer",
+ "columnsFrom": [
+ "manufacturer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "theme_parent_theme_id_theme_id_fk": {
+ "name": "theme_parent_theme_id_theme_id_fk",
+ "tableFrom": "theme",
+ "tableTo": "theme",
+ "columnsFrom": [
+ "parent_theme_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "collector": {
+ "name": "collector",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'blue'"
+ },
+ "birthday": {
+ "name": "birthday",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "collector_sort_idx": {
+ "name": "collector_sort_idx",
+ "columns": [
+ "sort_order",
+ "name"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "price_point": {
+ "name": "price_point",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'new'"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "observed_at": {
+ "name": "observed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "price_point_set_idx": {
+ "name": "price_point_set_idx",
+ "columns": [
+ "set_id",
+ "observed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "price_point_set_id_set_id_fk": {
+ "name": "price_point_set_id_set_id_fk",
+ "tableFrom": "price_point",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "price_point_positive": {
+ "name": "price_point_positive",
+ "value": "\"price_point\".\"price_cents\" >= 0"
+ }
+ }
+ },
+ "collection_item": {
+ "name": "collection_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collector_id": {
+ "name": "collector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'owned'"
+ },
+ "completeness": {
+ "name": "completeness",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_id": {
+ "name": "location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_at": {
+ "name": "acquired_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_price_cents": {
+ "name": "acquired_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_from": {
+ "name": "acquired_from",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gifted_by": {
+ "name": "gifted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lent_out_to": {
+ "name": "lent_out_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "collection_item_set_idx": {
+ "name": "collection_item_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ },
+ "collection_item_collector_idx": {
+ "name": "collection_item_collector_idx",
+ "columns": [
+ "collector_id"
+ ],
+ "isUnique": false
+ },
+ "collection_item_status_idx": {
+ "name": "collection_item_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "collection_item_location_idx": {
+ "name": "collection_item_location_idx",
+ "columns": [
+ "location_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "collection_item_set_id_set_id_fk": {
+ "name": "collection_item_set_id_set_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_item_collector_id_collector_id_fk": {
+ "name": "collection_item_collector_id_collector_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "collector",
+ "columnsFrom": [
+ "collector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_item_location_id_location_id_fk": {
+ "name": "collection_item_location_id_location_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "location",
+ "columnsFrom": [
+ "location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "location": {
+ "name": "location",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_location_id": {
+ "name": "parent_location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "location_parent_location_id_location_id_fk": {
+ "name": "location_parent_location_id_location_id_fk",
+ "tableFrom": "location",
+ "tableTo": "location",
+ "columnsFrom": [
+ "parent_location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "missing_part": {
+ "name": "missing_part",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collection_item_id": {
+ "name": "collection_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "part_number": {
+ "name": "part_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "color_name": {
+ "name": "color_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "resolved": {
+ "name": "resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "missing_part_item_idx": {
+ "name": "missing_part_item_idx",
+ "columns": [
+ "collection_item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "missing_part_collection_item_id_collection_item_id_fk": {
+ "name": "missing_part_collection_item_id_collection_item_id_fk",
+ "tableFrom": "missing_part",
+ "tableTo": "collection_item",
+ "columnsFrom": [
+ "collection_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "photo": {
+ "name": "photo",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "collection_item_id": {
+ "name": "collection_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbnail_path": {
+ "name": "thumbnail_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "photo_set_idx": {
+ "name": "photo_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ },
+ "photo_item_idx": {
+ "name": "photo_item_idx",
+ "columns": [
+ "collection_item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "photo_set_id_set_id_fk": {
+ "name": "photo_set_id_set_id_fk",
+ "tableFrom": "photo",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "photo_collection_item_id_collection_item_id_fk": {
+ "name": "photo_collection_item_id_collection_item_id_fk",
+ "tableFrom": "photo",
+ "tableTo": "collection_item",
+ "columnsFrom": [
+ "collection_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "photo_single_owner": {
+ "name": "photo_single_owner",
+ "value": "(\"photo\".\"set_id\" IS NOT NULL) <> (\"photo\".\"collection_item_id\" IS NOT NULL)"
+ }
+ }
+ },
+ "wishlist": {
+ "name": "wishlist",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collector_id": {
+ "name": "collector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "occasion": {
+ "name": "occasion",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'other'"
+ },
+ "event_date": {
+ "name": "event_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "surprise_mode": {
+ "name": "surprise_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "wishlist_share_token_unique": {
+ "name": "wishlist_share_token_unique",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ },
+ "wishlist_owner_idx": {
+ "name": "wishlist_owner_idx",
+ "columns": [
+ "owner_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "wishlist_owner_user_id_user_id_fk": {
+ "name": "wishlist_owner_user_id_user_id_fk",
+ "tableFrom": "wishlist",
+ "tableTo": "user",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wishlist_collector_id_collector_id_fk": {
+ "name": "wishlist_collector_id_collector_id_fk",
+ "tableFrom": "wishlist",
+ "tableTo": "collector",
+ "columnsFrom": [
+ "collector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "wishlist_item": {
+ "name": "wishlist_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "wishlist_id": {
+ "name": "wishlist_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reserved_by_name": {
+ "name": "reserved_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reserved_at": {
+ "name": "reserved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reservation_token_hash": {
+ "name": "reservation_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fulfilled_at": {
+ "name": "fulfilled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "wishlist_item_unique": {
+ "name": "wishlist_item_unique",
+ "columns": [
+ "wishlist_id",
+ "set_id"
+ ],
+ "isUnique": true
+ },
+ "wishlist_item_list_idx": {
+ "name": "wishlist_item_list_idx",
+ "columns": [
+ "wishlist_id",
+ "sort_order"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "wishlist_item_wishlist_id_wishlist_id_fk": {
+ "name": "wishlist_item_wishlist_id_wishlist_id_fk",
+ "tableFrom": "wishlist_item",
+ "tableTo": "wishlist",
+ "columnsFrom": [
+ "wishlist_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wishlist_item_set_id_set_id_fk": {
+ "name": "wishlist_item_set_id_set_id_fk",
+ "tableFrom": "wishlist_item",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "wishlist_item_priority": {
+ "name": "wishlist_item_priority",
+ "value": "\"wishlist_item\".\"priority\" BETWEEN 1 AND 3"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..d93cc42
--- /dev/null
+++ b/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,2094 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "f27f67e4-d146-4d63-b7cc-78bf5b0c686d",
+ "prevId": "36e88ca3-098f-40f0-9253-abe30ff27c47",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_user_idx": {
+ "name": "account_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "used_by_user_id": {
+ "name": "used_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_token_hash_unique": {
+ "name": "invite_token_hash_unique",
+ "columns": [
+ "token_hash"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_created_by_user_id_user_id_fk": {
+ "name": "invite_created_by_user_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invite_used_by_user_id_user_id_fk": {
+ "name": "invite_used_by_user_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": [
+ "used_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passkey": {
+ "name": "passkey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passkey_credential_id_unique": {
+ "name": "passkey_credential_id_unique",
+ "columns": [
+ "credential_id"
+ ],
+ "isUnique": true
+ },
+ "passkey_user_idx": {
+ "name": "passkey_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rate_limit": {
+ "name": "rate_limit",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "session_user_idx": {
+ "name": "session_user_idx",
+ "columns": [
+ "user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ "identifier"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "instruction": {
+ "name": "instruction",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_count": {
+ "name": "page_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "instruction_set_idx": {
+ "name": "instruction_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "instruction_set_id_set_id_fk": {
+ "name": "instruction_set_id_set_id_fk",
+ "tableFrom": "instruction",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "instruction_source": {
+ "name": "instruction_source",
+ "value": "(\"instruction\".\"kind\" = 'pdf_upload' AND \"instruction\".\"file_path\" IS NOT NULL AND \"instruction\".\"url\" IS NULL)\n OR (\"instruction\".\"kind\" = 'external_url' AND \"instruction\".\"url\" IS NOT NULL AND \"instruction\".\"file_path\" IS NULL)"
+ }
+ }
+ },
+ "manufacturer": {
+ "name": "manufacturer",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "manufacturer_slug_unique": {
+ "name": "manufacturer_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "set": {
+ "name": "set",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manufacturer_id": {
+ "name": "manufacturer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_id": {
+ "name": "theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "set_number": {
+ "name": "set_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "year": {
+ "name": "year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "piece_count": {
+ "name": "piece_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "minifig_count": {
+ "name": "minifig_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ean": {
+ "name": "ean",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "age_range": {
+ "name": "age_range",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_url": {
+ "name": "image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_path": {
+ "name": "image_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retail_price_cents": {
+ "name": "retail_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "external_ids": {
+ "name": "external_ids",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "set_manufacturer_number_unique": {
+ "name": "set_manufacturer_number_unique",
+ "columns": [
+ "manufacturer_id",
+ "set_number"
+ ],
+ "isUnique": true
+ },
+ "set_name_idx": {
+ "name": "set_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "set_ean_idx": {
+ "name": "set_ean_idx",
+ "columns": [
+ "ean"
+ ],
+ "isUnique": false
+ },
+ "set_theme_idx": {
+ "name": "set_theme_idx",
+ "columns": [
+ "theme_id"
+ ],
+ "isUnique": false
+ },
+ "set_year_idx": {
+ "name": "set_year_idx",
+ "columns": [
+ "year"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "set_manufacturer_id_manufacturer_id_fk": {
+ "name": "set_manufacturer_id_manufacturer_id_fk",
+ "tableFrom": "set",
+ "tableTo": "manufacturer",
+ "columnsFrom": [
+ "manufacturer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "set_theme_id_theme_id_fk": {
+ "name": "set_theme_id_theme_id_fk",
+ "tableFrom": "set",
+ "tableTo": "theme",
+ "columnsFrom": [
+ "theme_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "set_tag": {
+ "name": "set_tag",
+ "columns": {
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "set_tag_set_id_set_id_fk": {
+ "name": "set_tag_set_id_set_id_fk",
+ "tableFrom": "set_tag",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "set_tag_tag_id_tag_id_fk": {
+ "name": "set_tag_tag_id_tag_id_fk",
+ "tableFrom": "set_tag",
+ "tableTo": "tag",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "set_tag_set_id_tag_id_pk": {
+ "columns": [
+ "set_id",
+ "tag_id"
+ ],
+ "name": "set_tag_set_id_tag_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tag": {
+ "name": "tag",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tag_slug_unique": {
+ "name": "tag_slug_unique",
+ "columns": [
+ "slug"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "theme": {
+ "name": "theme",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manufacturer_id": {
+ "name": "manufacturer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_theme_id": {
+ "name": "parent_theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "theme_manufacturer_idx": {
+ "name": "theme_manufacturer_idx",
+ "columns": [
+ "manufacturer_id"
+ ],
+ "isUnique": false
+ },
+ "theme_unique_name": {
+ "name": "theme_unique_name",
+ "columns": [
+ "manufacturer_id",
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "theme_manufacturer_id_manufacturer_id_fk": {
+ "name": "theme_manufacturer_id_manufacturer_id_fk",
+ "tableFrom": "theme",
+ "tableTo": "manufacturer",
+ "columnsFrom": [
+ "manufacturer_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "theme_parent_theme_id_theme_id_fk": {
+ "name": "theme_parent_theme_id_theme_id_fk",
+ "tableFrom": "theme",
+ "tableTo": "theme",
+ "columnsFrom": [
+ "parent_theme_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "collector": {
+ "name": "collector",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'blue'"
+ },
+ "birthday": {
+ "name": "birthday",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "collector_sort_idx": {
+ "name": "collector_sort_idx",
+ "columns": [
+ "sort_order",
+ "name"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "price_point": {
+ "name": "price_point",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'new'"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "observed_at": {
+ "name": "observed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "price_point_set_idx": {
+ "name": "price_point_set_idx",
+ "columns": [
+ "set_id",
+ "observed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "price_point_set_id_set_id_fk": {
+ "name": "price_point_set_id_set_id_fk",
+ "tableFrom": "price_point",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "price_point_positive": {
+ "name": "price_point_positive",
+ "value": "\"price_point\".\"price_cents\" >= 0"
+ }
+ }
+ },
+ "collection_item": {
+ "name": "collection_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collector_id": {
+ "name": "collector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'owned'"
+ },
+ "completeness": {
+ "name": "completeness",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'unknown'"
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "location_id": {
+ "name": "location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_at": {
+ "name": "acquired_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_price_cents": {
+ "name": "acquired_price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acquired_from": {
+ "name": "acquired_from",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "gifted_by": {
+ "name": "gifted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lent_out_to": {
+ "name": "lent_out_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "collection_item_set_idx": {
+ "name": "collection_item_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ },
+ "collection_item_collector_idx": {
+ "name": "collection_item_collector_idx",
+ "columns": [
+ "collector_id"
+ ],
+ "isUnique": false
+ },
+ "collection_item_status_idx": {
+ "name": "collection_item_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "collection_item_location_idx": {
+ "name": "collection_item_location_idx",
+ "columns": [
+ "location_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "collection_item_set_id_set_id_fk": {
+ "name": "collection_item_set_id_set_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_item_collector_id_collector_id_fk": {
+ "name": "collection_item_collector_id_collector_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "collector",
+ "columnsFrom": [
+ "collector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_item_location_id_location_id_fk": {
+ "name": "collection_item_location_id_location_id_fk",
+ "tableFrom": "collection_item",
+ "tableTo": "location",
+ "columnsFrom": [
+ "location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "location": {
+ "name": "location",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_location_id": {
+ "name": "parent_location_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "location_parent_location_id_location_id_fk": {
+ "name": "location_parent_location_id_location_id_fk",
+ "tableFrom": "location",
+ "tableTo": "location",
+ "columnsFrom": [
+ "parent_location_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "missing_part": {
+ "name": "missing_part",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collection_item_id": {
+ "name": "collection_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "part_number": {
+ "name": "part_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "color_name": {
+ "name": "color_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "resolved": {
+ "name": "resolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "missing_part_item_idx": {
+ "name": "missing_part_item_idx",
+ "columns": [
+ "collection_item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "missing_part_collection_item_id_collection_item_id_fk": {
+ "name": "missing_part_collection_item_id_collection_item_id_fk",
+ "tableFrom": "missing_part",
+ "tableTo": "collection_item",
+ "columnsFrom": [
+ "collection_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "photo": {
+ "name": "photo",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "collection_item_id": {
+ "name": "collection_item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbnail_path": {
+ "name": "thumbnail_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "caption": {
+ "name": "caption",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "photo_set_idx": {
+ "name": "photo_set_idx",
+ "columns": [
+ "set_id"
+ ],
+ "isUnique": false
+ },
+ "photo_item_idx": {
+ "name": "photo_item_idx",
+ "columns": [
+ "collection_item_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "photo_set_id_set_id_fk": {
+ "name": "photo_set_id_set_id_fk",
+ "tableFrom": "photo",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "photo_collection_item_id_collection_item_id_fk": {
+ "name": "photo_collection_item_id_collection_item_id_fk",
+ "tableFrom": "photo",
+ "tableTo": "collection_item",
+ "columnsFrom": [
+ "collection_item_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "photo_single_owner": {
+ "name": "photo_single_owner",
+ "value": "(\"photo\".\"set_id\" IS NOT NULL) <> (\"photo\".\"collection_item_id\" IS NOT NULL)"
+ }
+ }
+ },
+ "wishlist": {
+ "name": "wishlist",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collector_id": {
+ "name": "collector_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "occasion": {
+ "name": "occasion",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'other'"
+ },
+ "event_date": {
+ "name": "event_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "surprise_mode": {
+ "name": "surprise_mode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_reminder_at": {
+ "name": "last_reminder_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "wishlist_share_token_unique": {
+ "name": "wishlist_share_token_unique",
+ "columns": [
+ "share_token"
+ ],
+ "isUnique": true
+ },
+ "wishlist_owner_idx": {
+ "name": "wishlist_owner_idx",
+ "columns": [
+ "owner_user_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "wishlist_owner_user_id_user_id_fk": {
+ "name": "wishlist_owner_user_id_user_id_fk",
+ "tableFrom": "wishlist",
+ "tableTo": "user",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wishlist_collector_id_collector_id_fk": {
+ "name": "wishlist_collector_id_collector_id_fk",
+ "tableFrom": "wishlist",
+ "tableTo": "collector",
+ "columnsFrom": [
+ "collector_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "wishlist_item": {
+ "name": "wishlist_item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "wishlist_id": {
+ "name": "wishlist_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "set_id": {
+ "name": "set_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 2
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "reserved_by_name": {
+ "name": "reserved_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reserved_at": {
+ "name": "reserved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reservation_token_hash": {
+ "name": "reservation_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fulfilled_at": {
+ "name": "fulfilled_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "wishlist_item_unique": {
+ "name": "wishlist_item_unique",
+ "columns": [
+ "wishlist_id",
+ "set_id"
+ ],
+ "isUnique": true
+ },
+ "wishlist_item_list_idx": {
+ "name": "wishlist_item_list_idx",
+ "columns": [
+ "wishlist_id",
+ "sort_order"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "wishlist_item_wishlist_id_wishlist_id_fk": {
+ "name": "wishlist_item_wishlist_id_wishlist_id_fk",
+ "tableFrom": "wishlist_item",
+ "tableTo": "wishlist",
+ "columnsFrom": [
+ "wishlist_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "wishlist_item_set_id_set_id_fk": {
+ "name": "wishlist_item_set_id_set_id_fk",
+ "tableFrom": "wishlist_item",
+ "tableTo": "set",
+ "columnsFrom": [
+ "set_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "wishlist_item_priority": {
+ "name": "wishlist_item_priority",
+ "value": "\"wishlist_item\".\"priority\" BETWEEN 1 AND 3"
+ }
+ }
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 19c4a53..f5de118 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -8,6 +8,27 @@
"when": 1788726503038,
"tag": "0000_rapid_gargoyle",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1788763704015,
+ "tag": "0001_lumpy_bill_hollister",
+ "breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "6",
+ "when": 1788765229376,
+ "tag": "0002_aspiring_whiplash",
+ "breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "6",
+ "when": 1788765478965,
+ "tag": "0003_powerful_sally_floyd",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts
index 695c6af..c0b784e 100644
--- a/e2e/screenshots.spec.ts
+++ b/e2e/screenshots.spec.ts
@@ -66,7 +66,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await page.getByRole('button', { name: 'Auswählen' }).click();
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
- const cards = page.locator('button[aria-pressed]');
+ // data-set-card, not aria-pressed: the collector switcher uses that too.
+ const cards = page.locator('button[data-set-card]');
await cards.nth(0).click();
await cards.nth(1).click();
await expect(page.getByText('2 ausgewählt')).toBeVisible();
@@ -160,4 +161,33 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/verwaltung/daten');
await expect(page.getByText('Alles als ZIP')).toBeVisible();
await shoot(page, '20-daten');
+
+ // --- 16. collectors -------------------------------------------------------
+ await visit(page, '/verwaltung/sammler');
+ await expect(page.getByText('Der Katalog ist gemeinsam')).toBeVisible();
+ await shoot(page, '21-sammler');
+
+ // The switcher only exists because the seed created two collectors.
+ await visit(page, '/katalog?besitz=fehlt');
+ await expect(page.getByRole('button', { name: /Max/ })).toBeVisible();
+ await page.getByRole('button', { name: /Max/ }).click();
+ await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
+ await shoot(page, '22-sammler-filter');
+
+ // --- 17. prices -----------------------------------------------------------
+ await visit(page, '/katalog?q=60398');
+ await page.getByText('Familienhaus und Elektroauto').first().click();
+ await expect(page.getByText('Preis und Wertentwicklung')).toBeVisible();
+ await page.getByRole('button', { name: 'Preis notieren' }).click();
+ // Role, not label: the label text carries the required marker.
+ await page.getByRole('textbox', { name: 'Preis', exact: true }).fill('79,99');
+ await page.getByLabel('Wo').fill('Amazon');
+ await page.getByRole('button', { name: 'Speichern' }).click();
+ await expect(page.getByText('Amazon')).toBeVisible();
+ await shoot(page, '23-preise');
+
+ // --- 18. reminders --------------------------------------------------------
+ await visit(page, '/verwaltung/erinnerungen');
+ await expect(page.getByText('Erinnerungen').first()).toBeVisible();
+ await shoot(page, '24-erinnerungen');
});
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
index c3f21cf..659e806 100644
--- a/e2e/smoke.spec.ts
+++ b/e2e/smoke.spec.ts
@@ -81,6 +81,30 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
await visitorContext.close();
});
+test('Dublettenpruefung warnt vor einem fast gleichen Set', async ({ page }) => {
+ await page.goto('/anmelden');
+ await page.getByLabel('E-Mail').fill('test@bricktrack.local');
+ await page.getByLabel('Passwort').fill('smoketest-passwort');
+ await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
+ await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
+
+ // Dasselbe Set noch einmal, mit Tippfehler im Namen und anderer Nummer.
+ await page.goto('/katalog/neu');
+ await page.getByLabel('Setnummer').fill('60399');
+ await page
+ .getByRole('textbox', { name: 'Name', exact: true })
+ .fill('Familienhaus und Elektroauo');
+ await page.getByRole('button', { name: 'Set anlegen' }).click();
+
+ await expect(page.getByText('Gibt es das vielleicht schon?')).toBeVisible();
+ await expect(page.getByText('Sehr ähnlicher Name')).toBeVisible();
+
+ // Zurueck ins Formular: nichts wurde angelegt.
+ await page.getByRole('button', { name: 'Zurück zum Formular' }).click();
+ await page.goto('/katalog?q=60399');
+ await expect(page.getByText('0 Sets')).toBeVisible();
+});
+
test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => {
// The admin from the first test is still the only account, so log in.
await page.goto('/anmelden');
diff --git a/package.json b/package.json
index 06c3cb8..e593f1a 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"lucide-react": "^1.41.0",
"next": "15.5.25",
"next-themes": "^0.4.6",
+ "nodemailer": "^10.0.0",
"pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7",
"react": "19.1.0",
@@ -52,6 +53,7 @@
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
+ "@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8fef224..8784d08 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ nodemailer:
+ specifier: ^10.0.0
+ version: 10.0.0
pdfjs-dist:
specifier: ^6.3.289
version: 6.3.289
@@ -90,6 +93,9 @@ importers:
'@types/node':
specifier: ^20
version: 20.19.43
+ '@types/nodemailer':
+ specifier: ^8.0.1
+ version: 8.0.1
'@types/react':
specifier: ^19
version: 19.2.18
@@ -2262,6 +2268,9 @@ packages:
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+ '@types/nodemailer@8.0.1':
+ resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
+
'@types/react-dom@19.2.7':
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
peerDependencies:
@@ -4135,6 +4144,10 @@ packages:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
+ nodemailer@10.0.0:
+ resolution: {integrity: sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==}
+ engines: {node: '>=20.0.0'}
+
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -6950,6 +6963,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/nodemailer@8.0.1':
+ dependencies:
+ '@types/node': 20.19.43
+
'@types/react-dom@19.2.7(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@@ -8805,6 +8822,8 @@ snapshots:
node-releases@2.0.54: {}
+ nodemailer@10.0.0: {}
+
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
diff --git a/scripts/e2e-prepare.mjs b/scripts/e2e-prepare.mjs
index a0aff46..db802e0 100644
--- a/scripts/e2e-prepare.mjs
+++ b/scripts/e2e-prepare.mjs
@@ -17,6 +17,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
+// A container left behind by an aborted run would hold the port.
+try {
+ execFileSync('docker', ['rm', '-f', 'bricktrack-e2e'], { stdio: 'ignore' });
+} catch {
+ // Nothing to remove - the normal case.
+}
+
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
console.log('[e2e] Image ist bereit.');
diff --git a/scripts/screenshots-prepare.mjs b/scripts/screenshots-prepare.mjs
index 69ef5ef..05224ca 100644
--- a/scripts/screenshots-prepare.mjs
+++ b/scripts/screenshots-prepare.mjs
@@ -16,6 +16,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`);
+// A container left behind by an aborted run would hold the port.
+try {
+ execFileSync('docker', ['rm', '-f', 'bricktrack-shots'], { stdio: 'ignore' });
+} catch {
+ // Nothing to remove - the normal case.
+}
+
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
console.log('[screenshots] Image ist bereit.');
diff --git a/scripts/seed.ts b/scripts/seed.ts
index fa10c3b..b533787 100644
--- a/scripts/seed.ts
+++ b/scripts/seed.ts
@@ -33,11 +33,12 @@ type SeedSet = {
minifigCount: number | null;
retailPriceCents: number;
ageRange: string;
- /** Copies owned - 0 means the set is only in the catalog. */
+ /** Copies owned - absent means the set is only in the catalog. */
owned?: {
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
completeness: 'complete' | 'missing_parts' | 'unknown';
location: string;
+ collector: string;
giftedBy?: string;
};
};
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
+// Two collectors, so the shared catalog and the separate collections are
+// visible right away. With only one, the whole concept stays hidden in the UI.
+const COLLECTORS = [
+ { name: 'Max', color: 'blue' as const },
+ { name: 'Mia', color: 'rose' as const },
+];
+
const SETS: SeedSet[] = [
{
manufacturer: 'Lego',
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
minifigCount: 4,
retailPriceCents: 4999,
ageRange: '6-12',
- owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
+ owned: {
+ status: 'built',
+ completeness: 'complete',
+ location: 'Regal Kinderzimmer',
+ collector: 'Max',
+ },
},
{
manufacturer: 'Lego',
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
status: 'owned',
completeness: 'missing_parts',
location: 'Kiste Keller 3',
+ collector: 'Max',
giftedBy: 'Oma Helga',
},
},
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
minifigCount: null,
retailPriceCents: 999,
ageRange: '7-14',
- owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' },
+ owned: {
+ status: 'disassembled',
+ completeness: 'unknown',
+ location: 'Kiste Keller 3',
+ collector: 'Mia',
+ },
},
{
manufacturer: 'Lego',
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
minifigCount: 5,
retailPriceCents: 12999,
ageRange: '4-10',
- owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
+ owned: {
+ status: 'built',
+ completeness: 'complete',
+ location: 'Regal Kinderzimmer',
+ collector: 'Mia',
+ },
},
{
manufacturer: 'Playmobil',
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map) {
return byKey;
}
+function seedCollectors() {
+ const byName = new Map();
+
+ COLLECTORS.forEach((entry, index) => {
+ const all = db
+ .select({ id: schema.collector.id, name: schema.collector.name })
+ .from(schema.collector)
+ .all();
+
+ const existing = all.find((row) => row.name === entry.name);
+ if (existing) {
+ byName.set(entry.name, existing.id);
+ return;
+ }
+
+ // A fresh install already has the collector the migration created. Rather
+ // than adding a third one next to it, the seed takes it over.
+ const untouchedDefault = all.find(
+ (row) =>
+ row.name === 'Sammlung' &&
+ !db
+ .select({ id: schema.collectionItem.id })
+ .from(schema.collectionItem)
+ .where(eq(schema.collectionItem.collectorId, row.id))
+ .get(),
+ );
+
+ if (untouchedDefault) {
+ db.update(schema.collector)
+ .set({ name: entry.name, color: entry.color, sortOrder: index, updatedAt: now })
+ .where(eq(schema.collector.id, untouchedDefault.id))
+ .run();
+ byName.set(entry.name, untouchedDefault.id);
+ return;
+ }
+
+ const newId = id();
+ db.insert(schema.collector)
+ .values({
+ id: newId,
+ name: entry.name,
+ color: entry.color,
+ sortOrder: index,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .run();
+ byName.set(entry.name, newId);
+ });
+
+ return byName;
+}
+
function seedLocations() {
const byName = new Map();
@@ -270,6 +347,7 @@ function seedSets(
manufacturers: Map,
themes: Map,
locations: Map,
+ collectors: Map,
) {
const setIds = new Map();
@@ -317,6 +395,7 @@ function seedSets(
completeness: entry.owned.completeness,
condition: 'good',
locationId: locations.get(entry.owned.location) ?? null,
+ collectorId: collectors.get(entry.owned.collector) ?? null,
acquiredAt: new Date(entry.year, 11, 24),
acquiredPriceCents: entry.retailPriceCents,
giftedBy: entry.owned.giftedBy ?? null,
@@ -330,7 +409,7 @@ function seedSets(
return setIds;
}
-function seedWishlist(setIds: Map) {
+function seedWishlist(setIds: Map, collectors: Map) {
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
if (!owner) {
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map) {
.values({
id: wishlistId,
ownerUserId: owner.id,
+ collectorId: collectors.get('Max') ?? null,
title: 'Weihnachten 2026',
occasion: 'christmas',
eventDate: new Date(2026, 11, 24),
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
const manufacturers = seedManufacturers();
const themes = seedThemes(manufacturers);
const locations = seedLocations();
-const setIds = seedSets(manufacturers, themes, locations);
-seedWishlist(setIds);
+const collectors = seedCollectors();
+const setIds = seedSets(manufacturers, themes, locations, collectors);
+seedWishlist(setIds, collectors);
console.log(
- `[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`,
+ `[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
+ `${themes.size} Themen, ${setIds.size} Sets.`,
);
sqlite.close();
diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx
index ac2add6..8650504 100644
--- a/src/app/(app)/katalog/[id]/page.tsx
+++ b/src/app/(app)/katalog/[id]/page.tsx
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
import { InstructionsSection } from '@/components/katalog/instructions-section';
import { PhotosSection } from '@/components/katalog/photos-section';
+import { PriceSection } from '@/components/katalog/price-section';
import { SetActions } from '@/components/katalog/set-actions';
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
import { Badge } from '@/components/ui/badge';
@@ -17,25 +18,37 @@ import {
listCollectionItemsForSet,
listInstructionsForSet,
listPhotosForSet,
+ listPricePoints,
} from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection';
+import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
-type Props = { params: Promise<{ id: string }> };
+type Props = {
+ params: Promise<{ id: string }>;
+ searchParams: Promise>;
+};
-export async function generateMetadata({ params }: Props): Promise {
+export async function generateMetadata({ params }: Pick): Promise {
const { id } = await params;
const set = getSetById(id);
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
}
-export default async function SetDetailPage({ params }: Props) {
+export default async function SetDetailPage({ params, searchParams }: Props) {
await requireUser();
const { id } = await params;
+ const query = await searchParams;
- const set = getSetById(id);
+ // Keeps the "vorhanden / fehlt" badge in step with the collector the user
+ // was looking at in the catalog.
+ const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
+ const collectorId = resolveCollectorId(rawCollector);
+ const collectors = listCollectors();
+
+ const set = getSetById(id, collectorId);
if (!set) notFound();
const image = setImageSrc(set);
@@ -43,11 +56,16 @@ export default async function SetDetailPage({ params }: Props) {
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
+ const collectorsById = new Map(collectors.map((row) => [row.id, row]));
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id),
+ collectorName: item.collectorId ? (collectorsById.get(item.collectorId)?.name ?? null) : null,
+ collectorColor: item.collectorId
+ ? (collectorsById.get(item.collectorId)?.color ?? null)
+ : null,
}));
const facts: Array<[string, string]> = [
@@ -113,6 +131,7 @@ export default async function SetDetailPage({ params }: Props) {
setName={`${set.setNumber} · ${set.name}`}
owned={owned}
wishlists={listWishlistTargets()}
+ collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
@@ -144,7 +163,24 @@ export default async function SetDetailPage({ params }: Props) {
In der Sammlung
-
+ ({ id: row.id, name: row.name }))}
+ />
+
+
+
+
+
+ Preis und Wertentwicklung
+
+
+
diff --git a/src/app/(app)/katalog/duplicate-actions.ts b/src/app/(app)/katalog/duplicate-actions.ts
new file mode 100644
index 0000000..f18cfcb
--- /dev/null
+++ b/src/app/(app)/katalog/duplicate-actions.ts
@@ -0,0 +1,100 @@
+'use server';
+
+import { and, eq, ne } from 'drizzle-orm';
+import { db } from '@/db';
+import { manufacturer, set } from '@/db/schema';
+import { actionData, type ActionResult } from '@/lib/action-result';
+import { requireUser } from '@/lib/session';
+import { isSimilarName } from '@/lib/similarity';
+
+export type DuplicateCandidate = {
+ id: string;
+ setNumber: string;
+ name: string;
+ manufacturerName: string;
+ /** Why we are showing it. */
+ reason: 'same-number' | 'similar-name' | 'number-elsewhere';
+};
+
+/**
+ * Looks for sets that the one being created might be a duplicate of.
+ *
+ * Three cases, in descending order of certainty:
+ * - the same number at the same manufacturer (that is a real duplicate and is
+ * rejected on save anyway),
+ * - a suspiciously similar name at the same manufacturer,
+ * - the same number at a different manufacturer, which usually means the
+ * wrong manufacturer was selected.
+ */
+export async function findDuplicateCandidates(input: {
+ manufacturerId: string;
+ setNumber: string;
+ name: string;
+ excludeSetId?: string;
+}): Promise> {
+ await requireUser();
+
+ const setNumber = input.setNumber?.trim() ?? '';
+ const name = input.name?.trim() ?? '';
+ if (!input.manufacturerId || (setNumber === '' && name === '')) {
+ return actionData({ candidates: [] });
+ }
+
+ const rows = db
+ .select({
+ id: set.id,
+ setNumber: set.setNumber,
+ name: set.name,
+ manufacturerId: set.manufacturerId,
+ manufacturerName: manufacturer.name,
+ })
+ .from(set)
+ .innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
+ .where(input.excludeSetId ? ne(set.id, input.excludeSetId) : undefined)
+ .all();
+
+ const candidates: DuplicateCandidate[] = [];
+
+ for (const row of rows) {
+ const sameManufacturer = row.manufacturerId === input.manufacturerId;
+ const sameNumber = setNumber !== '' && row.setNumber.toLowerCase() === setNumber.toLowerCase();
+
+ if (sameManufacturer && sameNumber) {
+ candidates.push({ ...row, reason: 'same-number' });
+ continue;
+ }
+ if (sameManufacturer && name !== '' && isSimilarName(row.name, name)) {
+ candidates.push({ ...row, reason: 'similar-name' });
+ continue;
+ }
+ if (!sameManufacturer && sameNumber) {
+ candidates.push({ ...row, reason: 'number-elsewhere' });
+ }
+ }
+
+ // Certain matches first, and never more than a handful.
+ const order: Record = {
+ 'same-number': 0,
+ 'similar-name': 1,
+ 'number-elsewhere': 2,
+ };
+ candidates.sort((a, b) => order[a.reason] - order[b.reason]);
+
+ return actionData({ candidates: candidates.slice(0, 5) });
+}
+
+/** Used by the import to point at an existing entry instead of failing blindly. */
+export async function findSetByNumber(
+ manufacturerId: string,
+ setNumber: string,
+): Promise> {
+ await requireUser();
+
+ const row = db
+ .select({ id: set.id })
+ .from(set)
+ .where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
+ .get();
+
+ return actionData(row ?? null);
+}
diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx
index b3f7f4d..e76a3d0 100644
--- a/src/app/(app)/katalog/page.tsx
+++ b/src/app/(app)/katalog/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
+import { CollectorFilter } from '@/components/layout/collector-filter';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
@@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { pluralize } from '@/lib/format';
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
+import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -27,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
const params = await searchParams;
const ownership = parseOwnershipFilter(single(params.besitz));
+ const collectors = listCollectors();
+ const collectorId = resolveCollectorId(single(params.sammler));
+ const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const yearParam = single(params.jahr);
const page = Number(single(params.seite) ?? '1') || 1;
@@ -36,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
themeId: single(params.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership,
+ collectorId,
page,
};
@@ -57,7 +63,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
{ownership === 'fehlt'
- ? 'Sets, die noch nicht in der Sammlung sind.'
+ ? activeCollector
+ ? `Sets, die ${activeCollector.name} noch nicht hat.`
+ : 'Sets, die noch nicht in der Sammlung sind.'
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
@@ -72,6 +80,11 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
+ ({ id: row.id, name: row.name, color: row.color }))}
+ allLabel="Alle zusammen"
+ />
+
@@ -79,7 +92,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
-
+
diff --git a/src/app/(app)/katalog/price-actions.ts b/src/app/(app)/katalog/price-actions.ts
new file mode 100644
index 0000000..8e73f76
--- /dev/null
+++ b/src/app/(app)/katalog/price-actions.ts
@@ -0,0 +1,61 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+import { db } from '@/db';
+import { pricePoint } from '@/db/schema';
+import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
+import { requireUser } from '@/lib/session';
+import { optionalDate, optionalText, optionalPriceCents } from '@/lib/validation';
+
+const schema = z.object({
+ priceCents: optionalPriceCents,
+ condition: z.enum(['new', 'used']).default('new'),
+ source: optionalText(80),
+ observedAt: optionalDate,
+ note: optionalText(200),
+});
+
+export async function addPricePoint(setId: string, input: unknown): Promise {
+ await requireUser();
+
+ const parsed = parseForm(schema, input);
+ if (!parsed.ok) return parsed;
+ if (parsed.data.priceCents === null) return actionError('Bitte gib einen Preis an.');
+
+ db.insert(pricePoint)
+ .values({
+ id: crypto.randomUUID(),
+ setId,
+ priceCents: parsed.data.priceCents,
+ condition: parsed.data.condition,
+ source: parsed.data.source,
+ // No date given means "seen today".
+ observedAt: parsed.data.observedAt ?? new Date(),
+ note: parsed.data.note,
+ createdAt: new Date(),
+ })
+ .run();
+
+ revalidatePath(`/katalog/${setId}`);
+ revalidatePath('/sammlung');
+ return actionOk();
+}
+
+export async function deletePricePoint(id: string): Promise {
+ await requireUser();
+
+ const existing = db
+ .select({ setId: pricePoint.setId })
+ .from(pricePoint)
+ .where(eq(pricePoint.id, id))
+ .get();
+ if (!existing) return actionOk();
+
+ db.delete(pricePoint).where(eq(pricePoint.id, id)).run();
+
+ revalidatePath(`/katalog/${existing.setId}`);
+ revalidatePath('/sammlung');
+ return actionOk();
+}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 3fe9e1b..69963e7 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -1,8 +1,13 @@
import { AppHeader } from '@/components/layout/app-header';
import { BottomNav } from '@/components/layout/bottom-nav';
+import { ensureReminderScheduler } from '@/lib/reminder-job';
import { requireUser } from '@/lib/session';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
+ // Second entry point besides the healthcheck, in case someone turns that
+ // off. The call is idempotent.
+ ensureReminderScheduler();
+
const user = await requireUser();
return (
diff --git a/src/app/(app)/sammlung/actions.ts b/src/app/(app)/sammlung/actions.ts
index a0b1ca3..2de91bb 100644
--- a/src/app/(app)/sammlung/actions.ts
+++ b/src/app/(app)/sammlung/actions.ts
@@ -11,6 +11,7 @@ import {
parseForm,
type ActionResult,
} from '@/lib/action-result';
+import { defaultCollectorId } from '@/lib/queries/collectors';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
@@ -32,17 +33,24 @@ function revalidateCollection(setId?: string) {
* Quick add from the set detail page: one tap, sensible defaults, details can
* be filled in later.
*/
-export async function addSetToCollection(setId: string): Promise> {
+export async function addSetToCollection(
+ setId: string,
+ collectorId?: string,
+): Promise> {
await requireUser();
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
if (!exists) return actionError('Das Set gibt es nicht mehr.');
+ // With a single collector nobody is asked - the copy simply belongs to them.
+ const owner = collectorId ?? defaultCollectorId();
+
const id = crypto.randomUUID();
db.insert(collectionItem)
.values({
id,
setId,
+ collectorId: owner,
status: 'owned',
completeness: 'unknown',
createdAt: now(),
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
db.update(collectionItem)
.set({
+ collectorId: parsed.data.collectorId ?? existing.collectorId,
status: parsed.data.status,
completeness: parsed.data.completeness,
condition: parsed.data.condition as Condition | null,
diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx
index fe47006..d9d0bfd 100644
--- a/src/app/(app)/sammlung/page.tsx
+++ b/src/app/(app)/sammlung/page.tsx
@@ -1,10 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
+import { CollectorFilter } from '@/components/layout/collector-filter';
import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { formatDate, formatNumber, pluralize } from '@/lib/format';
+import { collectorBadgeClass } from '@/lib/collector-colors';
import { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money';
@@ -15,6 +17,7 @@ import {
listCollectionItems,
type CollectionFilters as Filters,
} from '@/lib/queries/collection';
+import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session';
import type { CollectionStatus, Completeness } from '@/db/types';
@@ -32,23 +35,33 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
await requireUser();
const params = await searchParams;
+ const collectors = listCollectors();
+ const collectorId = resolveCollectorId(single(params.sammler));
+ const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
+
const filters: Filters = {
q: single(params.q),
status: single(params.status) as CollectionStatus | undefined,
completeness: single(params.zustand) as Completeness | undefined,
locationId: single(params.standort),
manufacturerId: single(params.hersteller),
+ collectorId,
};
const items = listCollectionItems(filters);
const openParts = countOpenMissingParts(items.map((item) => item.id));
- const stats = getCollectionStats();
- const missing = countMissingSets();
+ const stats = getCollectionStats(collectorId);
+ const missing = countMissingSets(collectorId);
const tiles = [
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
- { icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) },
+ {
+ icon: Euro,
+ // Notierte Preise gehen vor, sonst die UVP.
+ label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert',
+ value: formatEuro(stats.currentValueCents),
+ },
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
] as const;
@@ -56,9 +69,18 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
Sammlung
-
Alles, was tatsächlich da ist.
+
+ {activeCollector
+ ? `Alles, was ${activeCollector.name} tatsächlich hat.`
+ : 'Alles, was tatsächlich da ist.'}
+
+
({ id: row.id, name: row.name, color: row.color }))}
+ allLabel="Alle zusammen"
+ />
+
{tiles.map((tile) => (
@@ -134,6 +156,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
{item.completeness !== 'unknown' ? (
{completenessLabel(item.completeness)}
) : null}
+ {collectors.length > 1 && item.collectorName ? (
+
+ {item.collectorName}
+
+ ) : null}
{openParts.get(item.id) ? (
{openParts.get(item.id)} Fehlteile
) : null}
diff --git a/src/app/(app)/verwaltung/erinnerungen/actions.ts b/src/app/(app)/verwaltung/erinnerungen/actions.ts
new file mode 100644
index 0000000..5d37cb1
--- /dev/null
+++ b/src/app/(app)/verwaltung/erinnerungen/actions.ts
@@ -0,0 +1,51 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { actionData, actionError, type ActionResult } from '@/lib/action-result';
+import { env } from '@/env';
+import { sendMail, verifyMailConnection } from '@/lib/mailer';
+import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
+import { requireAdmin } from '@/lib/session';
+
+export async function testMailConnection(): Promise {
+ await requireAdmin();
+
+ const result = await verifyMailConnection();
+ return result.ok ? { ok: true } : actionError(result.error);
+}
+
+export async function sendTestMail(): Promise {
+ const admin = await requireAdmin();
+
+ if (!env.isMailConfigured) {
+ return actionError('Es ist kein SMTP-Server konfiguriert.');
+ }
+
+ const result = await sendMail({
+ to: admin.email,
+ subject: 'Bricktrack: Testnachricht',
+ text: [
+ 'Das hat geklappt.',
+ '',
+ 'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu',
+ 'Wunschlisten-Terminen.',
+ '',
+ env.BASE_URL,
+ ].join('\n'),
+ });
+
+ return result.ok ? { ok: true } : actionError(result.error);
+}
+
+/** Runs the check immediately instead of waiting for the next hour. */
+export async function runRemindersNow(): Promise> {
+ await requireAdmin();
+
+ if (!env.isMailConfigured) {
+ return actionError('Es ist kein SMTP-Server konfiguriert.');
+ }
+
+ const run = await runReminderJob();
+ revalidatePath('/verwaltung/erinnerungen');
+ return actionData(run);
+}
diff --git a/src/app/(app)/verwaltung/erinnerungen/page.tsx b/src/app/(app)/verwaltung/erinnerungen/page.tsx
new file mode 100644
index 0000000..f382a16
--- /dev/null
+++ b/src/app/(app)/verwaltung/erinnerungen/page.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from 'next';
+import { eq, sql } from 'drizzle-orm';
+import { db } from '@/db';
+import { wishlist } from '@/db/schema';
+import { env } from '@/env';
+import {
+ ReminderSettings,
+ type ReminderListRow,
+} from '@/components/verwaltung/reminder-settings';
+import { daysUntil } from '@/lib/reminders';
+import { requireAdmin } from '@/lib/session';
+
+export const metadata: Metadata = { title: 'Erinnerungen' };
+
+export default async function ErinnerungenPage() {
+ const admin = await requireAdmin();
+
+ const openItems = sql`(
+ select count(*) from wishlist_item wi
+ where wi.wishlist_id = ${wishlist.id}
+ and wi.reserved_at is null
+ and wi.fulfilled_at is null
+ )`.mapWith(Number);
+
+ const now = new Date();
+ const lists: ReminderListRow[] = db
+ .select({
+ id: wishlist.id,
+ title: wishlist.title,
+ eventDate: wishlist.eventDate,
+ lastReminderAt: wishlist.lastReminderAt,
+ openItemCount: openItems,
+ })
+ .from(wishlist)
+ .where(eq(wishlist.ownerUserId, admin.id))
+ .all()
+ .filter((row) => row.eventDate !== null)
+ .map((row) => ({
+ ...row,
+ daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null,
+ }))
+ .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
+
+ return (
+
+ );
+}
diff --git a/src/app/(app)/verwaltung/sammler/actions.ts b/src/app/(app)/verwaltung/sammler/actions.ts
new file mode 100644
index 0000000..a4eed88
--- /dev/null
+++ b/src/app/(app)/verwaltung/sammler/actions.ts
@@ -0,0 +1,160 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { asc, eq, sql } from 'drizzle-orm';
+import { db } from '@/db';
+import { collectionItem, collector, wishlist } from '@/db/schema';
+import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
+import { requireUser } from '@/lib/session';
+import { collectorSchema } from '@/lib/validation';
+
+function now() {
+ return new Date();
+}
+
+function revalidateEverything() {
+ // A collector shows up in the catalog, the collection and the wishlists.
+ revalidatePath('/', 'layout');
+}
+
+export async function createCollector(input: unknown): Promise {
+ await requireUser();
+
+ const parsed = parseForm(collectorSchema, input);
+ if (!parsed.ok) return parsed;
+
+ const [row] = db
+ .select({ next: sql`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
+ .from(collector)
+ .all();
+
+ db.insert(collector)
+ .values({
+ id: crypto.randomUUID(),
+ name: parsed.data.name,
+ color: parsed.data.color,
+ birthday: parsed.data.birthday,
+ notes: parsed.data.notes,
+ sortOrder: row?.next ?? 0,
+ createdAt: now(),
+ updatedAt: now(),
+ })
+ .run();
+
+ revalidateEverything();
+ return actionOk();
+}
+
+export async function updateCollector(id: string, input: unknown): Promise {
+ await requireUser();
+
+ const parsed = parseForm(collectorSchema, input);
+ if (!parsed.ok) return parsed;
+
+ db.update(collector)
+ .set({
+ name: parsed.data.name,
+ color: parsed.data.color,
+ birthday: parsed.data.birthday,
+ notes: parsed.data.notes,
+ updatedAt: now(),
+ })
+ .where(eq(collector.id, id))
+ .run();
+
+ revalidateEverything();
+ return actionOk();
+}
+
+/**
+ * Deleting a collector would take their whole collection with it, so it is
+ * only allowed while nothing is attached - and never for the last one.
+ */
+export async function deleteCollector(id: string): Promise {
+ await requireUser();
+
+ const remaining = db.select({ id: collector.id }).from(collector).all();
+ if (remaining.length <= 1) {
+ return actionError('Der letzte Sammler kann nicht gelöscht werden.');
+ }
+
+ const owned = db
+ .select({ id: collectionItem.id })
+ .from(collectionItem)
+ .where(eq(collectionItem.collectorId, id))
+ .get();
+ if (owned) {
+ return actionError(
+ 'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
+ );
+ }
+
+ const lists = db
+ .select({ id: wishlist.id })
+ .from(wishlist)
+ .where(eq(wishlist.collectorId, id))
+ .get();
+ if (lists) {
+ return actionError(
+ 'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
+ );
+ }
+
+ db.delete(collector).where(eq(collector.id, id)).run();
+
+ revalidateEverything();
+ return actionOk();
+}
+
+/** Moves every owned copy of one collector over to another. */
+export async function reassignCollectionItems(
+ fromCollectorId: string,
+ toCollectorId: string,
+): Promise {
+ await requireUser();
+
+ if (fromCollectorId === toCollectorId) return actionOk();
+
+ const target = db
+ .select({ id: collector.id })
+ .from(collector)
+ .where(eq(collector.id, toCollectorId))
+ .get();
+ if (!target) return actionError('Diesen Sammler gibt es nicht.');
+
+ db.update(collectionItem)
+ .set({ collectorId: toCollectorId, updatedAt: now() })
+ .where(eq(collectionItem.collectorId, fromCollectorId))
+ .run();
+
+ revalidateEverything();
+ return actionOk();
+}
+
+/** Reorders the collectors; the first one is the default for new entries. */
+export async function reorderCollectors(orderedIds: string[]): Promise {
+ await requireUser();
+
+ db.transaction((tx) => {
+ orderedIds.forEach((id, index) => {
+ tx.update(collector)
+ .set({ sortOrder: index, updatedAt: now() })
+ .where(eq(collector.id, id))
+ .run();
+ });
+ });
+
+ revalidateEverything();
+ return actionOk();
+}
+
+/** Every collector, ordered - for the selects in the forms. */
+export async function listCollectorOptions(): Promise> {
+ await requireUser();
+
+ return db
+ .select({ id: collector.id, name: collector.name })
+ .from(collector)
+ .orderBy(asc(collector.sortOrder), asc(collector.name))
+ .all();
+}
diff --git a/src/app/(app)/verwaltung/sammler/page.tsx b/src/app/(app)/verwaltung/sammler/page.tsx
new file mode 100644
index 0000000..4c76522
--- /dev/null
+++ b/src/app/(app)/verwaltung/sammler/page.tsx
@@ -0,0 +1,40 @@
+import type { Metadata } from 'next';
+import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
+import { formatDate, pluralize } from '@/lib/format';
+import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
+import { listCollectors } from '@/lib/queries/collectors';
+import { requireUser } from '@/lib/session';
+
+export const metadata: Metadata = { title: 'Sammler' };
+
+export default async function SammlerPage() {
+ await requireUser();
+
+ const collectors = listCollectors();
+ const rows: CollectorRowItem[] = collectors.map((row) => ({
+ id: row.id,
+ title: row.name,
+ subtitle: [
+ pluralize(row.itemCount, 'Set', 'Sets'),
+ COLLECTOR_COLOR_LABELS[row.color],
+ row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ color: row.color,
+ birthday: row.birthday,
+ notes: row.notes,
+ itemCount: row.itemCount,
+ }));
+
+ return (
+
+
+ Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
+ Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
+ taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
+
+
+
+ );
+}
diff --git a/src/app/(app)/wunschlisten/[id]/page.tsx b/src/app/(app)/wunschlisten/[id]/page.tsx
index fc433ff..515e3fa 100644
--- a/src/app/(app)/wunschlisten/[id]/page.tsx
+++ b/src/app/(app)/wunschlisten/[id]/page.tsx
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
+import { listCollectors } from '@/lib/queries/collectors';
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound();
- const items = listWishlistItems(list.id);
+ const collectors = listCollectors();
+ const activeCollector = collectors.find((row) => row.id === list.collectorId) ?? null;
+ const items = listWishlistItems(list.id, list.collectorId);
// The embedded picker: missing sets that are not on this list yet. It shares
// the URL parameters with the catalog, so the filters behave identically.
@@ -52,6 +56,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt',
excludeWishlistId: list.id,
+ collectorId: list.collectorId ?? undefined,
page,
});
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
{occasionLabel(list.occasion)}
{list.eventDate ? {formatDate(list.eventDate)} : null}
{list.surpriseMode ? Überraschungsmodus : null}
+ {collectors.length > 1 && activeCollector ? (
+
+ für {activeCollector.name}
+
+ ) : null}
{list.title}
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
({ id: row.id, name: row.name }))}
wishlist={{
id: list.id,
+ collectorId: list.collectorId,
title: list.title,
occasion: list.occasion,
eventDate: list.eventDate,
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
Aus fehlenden Sets hinzufügen
- Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel
+ {activeCollector
+ ? `Nur Sets, die ${activeCollector.name} noch nicht hat und die noch nicht auf dieser Liste stehen.`
+ : 'Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind.'}{' '} Kachel
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
sets={missing.rows}
wishlists={[{ id: list.id, title: list.title }]}
defaultWishlistId={list.id}
+ collectorId={list.collectorId ?? undefined}
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
/>
diff --git a/src/app/(app)/wunschlisten/actions.ts b/src/app/(app)/wunschlisten/actions.ts
index f71d08f..242945b 100644
--- a/src/app/(app)/wunschlisten/actions.ts
+++ b/src/app/(app)/wunschlisten/actions.ts
@@ -12,6 +12,7 @@ import {
type ActionResult,
} from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership';
+import { defaultCollectorId } from '@/lib/queries/collectors';
import {
findExistingWishlistSetIds,
getWishlist,
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise
@@ -24,7 +27,9 @@ export default async function WunschlistenPage() {
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
-
+ ({ id: row.id, name: row.name }))}
+ />
{lists.length === 0 ? (
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
+ {collectors.length > 1 && list.collectorName ? (
+
+ {list.collectorName}
+
+ ) : null}
{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}
{list.openCount > 0 ? (
{list.openCount} noch offen
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index c774a02..84d744e 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/db';
+import { ensureReminderScheduler } from '@/lib/reminder-job';
export const dynamic = 'force-dynamic';
@@ -9,6 +10,10 @@ export const dynamic = 'force-dynamic';
* file is readable - a healthy Node process on a broken volume is not healthy.
*/
export function GET() {
+ // The container polls this every 30s, which makes it the earliest reliable
+ // moment to have the reminder scheduler running. Repeat calls are free.
+ ensureReminderScheduler();
+
try {
db.get(sql`select 1`);
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
diff --git a/src/app/w/[shareToken]/page.tsx b/src/app/w/[shareToken]/page.tsx
index 0e1639c..2ebc158 100644
--- a/src/app/w/[shareToken]/page.tsx
+++ b/src/app/w/[shareToken]/page.tsx
@@ -27,7 +27,7 @@ export default async function PublicWishlistPage({ params }: Props) {
const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound();
- const rows = listWishlistItems(list.id);
+ const rows = listWishlistItems(list.id, list.collectorId);
// Fulfilled wishes are gone from the public view: the present is already
// under the tree, no reason to let someone buy it twice.
diff --git a/src/components/katalog/catalog-grid.tsx b/src/components/katalog/catalog-grid.tsx
index 7c4fcfb..c24b85c 100644
--- a/src/components/katalog/catalog-grid.tsx
+++ b/src/components/katalog/catalog-grid.tsx
@@ -33,6 +33,8 @@ type Props = {
wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string;
+ /** A list created from a filtered view belongs to that collector. */
+ collectorId?: string;
emptyState?: React.ReactNode;
};
@@ -43,7 +45,13 @@ type Props = {
* On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist.
*/
-export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
+export function CatalogGrid({
+ sets,
+ wishlists,
+ defaultWishlistId,
+ collectorId,
+ emptyState,
+}: Props) {
const router = useRouter();
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState>(new Set());
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
title: newTitle,
occasion: 'other',
isPublic: 'on',
+ collectorId: collectorId ?? '',
});
if (!created.ok) {
toast.error(created.error);
@@ -199,6 +208,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
toggle(item.id)}
diff --git a/src/components/katalog/price-section.tsx b/src/components/katalog/price-section.tsx
new file mode 100644
index 0000000..2f40c85
--- /dev/null
+++ b/src/components/katalog/price-section.tsx
@@ -0,0 +1,206 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import { Loader2, Plus, TrendingDown, TrendingUp, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+import { addPricePoint, deletePricePoint } from '@/app/(app)/katalog/price-actions';
+import { formatDate, toDateInputValue } from '@/lib/format';
+import { formatEuro } from '@/lib/money';
+import { priceTrend, type PriceObservation } from '@/lib/prices';
+import type { PricePointRow } from '@/lib/queries/catalog';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { NativeSelect } from '@/components/ui/native-select';
+
+const CONDITION_LABELS: Record = {
+ new: 'Neu',
+ used: 'Gebraucht',
+};
+
+/**
+ * Observed prices for one set.
+ *
+ * The point is not the single current price but the development: what a set
+ * did since it was bought. Everything here is maintained by hand - no price
+ * service is queried, and nothing leaves the server.
+ */
+export function PriceSection({
+ setId,
+ prices,
+ retailPriceCents,
+}: {
+ setId: string;
+ prices: PricePointRow[];
+ retailPriceCents: number | null;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [open, setOpen] = useState(false);
+
+ const observations: PriceObservation[] = prices.map((price) => ({
+ priceCents: price.priceCents,
+ condition: price.condition,
+ observedAt: price.observedAt,
+ }));
+ const trend = priceTrend(observations, retailPriceCents);
+ const gained = (trend.changeCents ?? 0) > 0;
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ const form = event.currentTarget;
+ const input = Object.fromEntries(new FormData(form).entries());
+
+ startTransition(async () => {
+ const result = await addPricePoint(setId, input);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ form.reset();
+ setOpen(false);
+ toast.success('Preis notiert.');
+ router.refresh();
+ });
+ }
+
+ function remove(id: string) {
+ startTransition(async () => {
+ const result = await deletePricePoint(id);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ toast.success('Eintrag gelöscht.');
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+
+
Ursprünglich
+ {formatEuro(trend.baseCents)}
+
+
+
Aktuell
+ {formatEuro(trend.currentCents)}
+
+
+
Entwicklung
+
+ {trend.changePercent === null ? (
+ –
+ ) : (
+
+ {gained ? (
+
+ ) : (
+
+ )}
+ {trend.changePercent > 0 ? '+' : ''}
+ {trend.changePercent.toLocaleString('de-DE')} %
+
+ )}
+
+
+
+
+ {prices.length > 0 ? (
+
+ {prices.map((price) => (
+
+
+
+ {formatEuro(price.priceCents)}{' '}
+ {CONDITION_LABELS[price.condition]}
+
+
+ {[formatDate(price.observedAt), price.source, price.note]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ remove(price.id)}
+ disabled={pending}
+ >
+
+
+
+ ))}
+
+ ) : (
+
+ Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets.
+
+ )}
+
+ {open ? (
+
+ ) : (
+
setOpen(true)}>
+
+ Preis notieren
+
+ )}
+
+ );
+}
diff --git a/src/components/katalog/set-actions.tsx b/src/components/katalog/set-actions.tsx
index 3ec9f6d..1a0b777 100644
--- a/src/components/katalog/set-actions.tsx
+++ b/src/components/katalog/set-actions.tsx
@@ -38,32 +38,47 @@ export function SetActions({
setName,
owned,
wishlists,
+ collectors,
}: {
setId: string;
setName: string;
owned: boolean;
wishlists: Array<{ id: string; title: string }>;
+ /** More than one turns "Zur Sammlung" into a question of "for whom". */
+ collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
+ const [collectorOpen, setCollectorOpen] = useState(false);
+ const [collectorId, setCollectorId] = useState(collectors[0]?.id ?? '');
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
- function addToCollection() {
+ function addToCollection(forCollectorId?: string) {
startTransition(async () => {
- const result = await addSetToCollection(setId);
+ const result = await addSetToCollection(setId, forCollectorId);
if (!result.ok) {
toast.error(result.error);
return;
}
- toast.success('Zur Sammlung hinzugefügt.');
+ const owner = collectors.find((entry) => entry.id === forCollectorId);
+ toast.success(
+ owner ? `Zur Sammlung von ${owner.name} hinzugefügt.` : 'Zur Sammlung hinzugefügt.',
+ );
+ setCollectorOpen(false);
router.refresh();
});
}
+ // With a single collector there is nothing to ask.
+ function onAddClick() {
+ if (collectors.length > 1) setCollectorOpen(true);
+ else addToCollection();
+ }
+
function addToWishlist() {
startTransition(async () => {
let wishlistId = targetId;
@@ -111,7 +126,7 @@ export function SetActions({
return (
-
+
{pending ? : }
Zur Sammlung hinzufügen
@@ -138,6 +153,39 @@ export function SetActions({
) : null}
+
+
+
+ Wer bekommt das Set?
+ {setName}
+
+
+
+ setCollectorId(event.target.value)}
+ >
+ {collectors.map((entry) => (
+
+ {entry.name}
+
+ ))}
+
+
+
+
+ setCollectorOpen(false)} disabled={pending}>
+ Abbrechen
+
+ addToCollection(collectorId)} disabled={pending}>
+ {pending ? : }
+ Hinzufügen
+
+
+
+
+
diff --git a/src/components/katalog/set-form.tsx b/src/components/katalog/set-form.tsx
index 2a95a80..0b2ac7e 100644
--- a/src/components/katalog/set-form.tsx
+++ b/src/components/katalog/set-form.tsx
@@ -1,13 +1,28 @@
'use client';
import { useRouter } from 'next/navigation';
-import { useState, useTransition } from 'react';
+import { useRef, useState, useTransition } from 'react';
+import Link from 'next/link';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
+import {
+ findDuplicateCandidates,
+ type DuplicateCandidate,
+} from '@/app/(app)/katalog/duplicate-actions';
import { centsToInput } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Button } from '@/components/ui/button';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
@@ -46,6 +61,8 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
const [manufacturerId, setManufacturerId] = useState(
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
);
+ const [duplicates, setDuplicates] = useState(null);
+ const pendingForm = useRef(null);
const existingImage = set ? setImageSrc(set) : null;
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
@@ -54,32 +71,70 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
- startTransition(async () => {
- if (set) {
- const result = await updateSet(set.id, formData);
- if (!result.ok) {
- toast.error(result.error);
+ // Ask before creating something that already exists. Editing skips this:
+ // there the entry is the one we would be warning about.
+ if (!set) {
+ startTransition(async () => {
+ const check = await findDuplicateCandidates({
+ manufacturerId: String(formData.get('manufacturerId') ?? ''),
+ setNumber: String(formData.get('setNumber') ?? ''),
+ name: String(formData.get('name') ?? ''),
+ });
+
+ if (check.ok && check.data.candidates.length > 0) {
+ pendingForm.current = formData;
+ setDuplicates(check.data.candidates);
return;
}
- toast.success('Set gespeichert.');
- router.push(`/katalog/${set.id}`);
- router.refresh();
- return;
- }
- const result = await createSet(formData);
- if (!result.ok) {
- toast.error(result.error);
- return;
- }
- toast.success('Set angelegt.');
- router.push(`/katalog/${result.data.id}`);
- router.refresh();
+ await save(formData);
+ });
+ return;
+ }
+
+ startTransition(() => save(formData));
+ }
+
+ async function save(formData: FormData) {
+ return new Promise((resolve) => {
+ startTransition(async () => {
+ if (set) {
+ const result = await updateSet(set.id, formData);
+ if (!result.ok) {
+ toast.error(result.error);
+ resolve();
+ return;
+ }
+ toast.success('Set gespeichert.');
+ router.push(`/katalog/${set.id}`);
+ router.refresh();
+ resolve();
+ return;
+ }
+
+ const result = await createSet(formData);
+ if (!result.ok) {
+ toast.error(result.error);
+ resolve();
+ return;
+ }
+ toast.success('Set angelegt.');
+ router.push(`/katalog/${result.data.id}`);
+ router.refresh();
+ resolve();
+ });
});
}
+ const REASON_LABELS: Record = {
+ 'same-number': 'Gleiche Setnummer beim gleichen Hersteller',
+ 'similar-name': 'Sehr ähnlicher Name',
+ 'number-elsewhere': 'Gleiche Setnummer bei einem anderen Hersteller',
+ };
+
return (
-
+
+
+ {
+ if (!open) {
+ setDuplicates(null);
+ pendingForm.current = null;
+ }
+ }}
+ >
+
+
+ Gibt es das vielleicht schon?
+
+ Diese Einträge sehen ähnlich aus. Schau kurz nach, bevor der Katalog zwei Mal
+ dasselbe Set enthält.
+
+
+
+
+
+
+ Zurück zum Formular
+ {
+ const formData = pendingForm.current;
+ setDuplicates(null);
+ pendingForm.current = null;
+ if (formData) void save(formData);
+ }}
+ >
+ Trotzdem anlegen
+
+
+
+
+ >
);
}
diff --git a/src/components/layout/collector-filter.tsx b/src/components/layout/collector-filter.tsx
new file mode 100644
index 0000000..bcd284c
--- /dev/null
+++ b/src/components/layout/collector-filter.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { useTransition } from 'react';
+import { cn } from 'cn';
+import { collectorDotClass } from '@/lib/collector-colors';
+
+export type CollectorOption = { id: string; name: string; color: string };
+
+/**
+ * Switches the view between collectors.
+ *
+ * Rendered only when there is more than one - with a single collector the
+ * whole question does not exist, and the app should not ask it. The choice
+ * lives in the URL (`?sammler=`), so a filtered view stays shareable and the
+ * back button behaves.
+ */
+export function CollectorFilter({
+ collectors,
+ allLabel = 'Alle',
+}: {
+ collectors: CollectorOption[];
+ allLabel?: string;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ if (collectors.length < 2) return null;
+
+ const current = searchParams.get('sammler') ?? '';
+
+ function select(id: string) {
+ const params = new URLSearchParams(searchParams.toString());
+ if (id) params.set('sammler', id);
+ else params.delete('sammler');
+ params.delete('seite');
+
+ const query = params.toString();
+ startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
+ }
+
+ const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
+
+ return (
+
+ {options.map((option) => {
+ const active = current === option.id;
+ return (
+ select(option.id)}
+ className={cn(
+ 'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
+ active
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {option.id ? (
+
+ ) : null}
+ {option.name}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/sammlung/collection-items-section.tsx b/src/components/sammlung/collection-items-section.tsx
index 5128f31..adc9af7 100644
--- a/src/components/sammlung/collection-items-section.tsx
+++ b/src/components/sammlung/collection-items-section.tsx
@@ -8,6 +8,7 @@ import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung
import type { MissingPartRow } from '@/lib/queries/collection';
import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
+import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
notes: string | null;
setName: string;
missingParts: MissingPartRow[];
+ collectorId: string | null;
+ collectorName: string | null;
+ collectorColor: string | null;
};
export function CollectionItemsSection({
items,
locations,
+ collectors,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
+ collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
+ {collectors.length > 1 && item.collectorName ? (
+
+ {item.collectorName}
+
+ ) : null}
{statusLabel(item.status)}
@@ -194,6 +205,22 @@ export function CollectionItemsSection({