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 + +![Sammler verwalten](bilder/21-sammler.png) + +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. + +![Nach Sammler filtern](bilder/22-sammler-filter.png) + +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 + +![Preis und Wertentwicklung](bilder/23-preise.png) + +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 + +![Erinnerungen](bilder/24-erinnerungen.png) + +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 }: + + ))} + + ) : ( +

+ Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets. +

+ )} + + {open ? ( +
+

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 (
- @@ -138,6 +153,39 @@ export function SetActions({

) : null} + + + + Wer bekommt das Set? + {setName} + + + + setCollectorId(event.target.value)} + > + {collectors.map((entry) => ( + + ))} + + + + + + + + + + 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. + + + +
    + {(duplicates ?? []).map((candidate) => ( +
  • + + {candidate.setNumber} · {candidate.name} + +

    + {candidate.manufacturerName} — {REASON_LABELS[candidate.reason]} +

    +
  • + ))} +
+ + + 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 ( + + ); +} 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({
    + {collectors.length > 1 ? ( + + + {collectors.map((entry) => ( + + ))} + + + ) : null} + {Object.entries(STATUS_LABELS).map(([value, label]) => ( diff --git a/src/components/verwaltung/collector-list.tsx b/src/components/verwaltung/collector-list.tsx new file mode 100644 index 0000000..5127f13 --- /dev/null +++ b/src/components/verwaltung/collector-list.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { + createCollector, + deleteCollector, + updateCollector, +} from '@/app/(app)/verwaltung/sammler/actions'; +import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors'; +import { toDateInputValue } from '@/lib/format'; +import { CrudSection, type CrudItem } from './crud-section'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; +import { Textarea } from '@/components/ui/textarea'; + +export type CollectorRowItem = CrudItem & { + color: string; + birthday: Date | null; + notes: string | null; + itemCount: number; +}; + +export function CollectorList({ collectors }: { collectors: CollectorRowItem[] }) { + return ( + + items={collectors} + emptyText="Noch kein Sammler angelegt." + createLabel="Sammler anlegen" + dialogTitle={(item) => (item ? 'Sammler bearbeiten' : 'Neuer Sammler')} + dialogDescription="Der Katalog bleibt gemeinsam – Sammlung und Wunschlisten gehören je einem Sammler." + onCreate={createCollector} + onUpdate={updateCollector} + onDelete={deleteCollector} + deleteWarning={(item) => + item.itemCount > 0 + ? `„${item.title}“ hat ${item.itemCount} Sets in der Sammlung. Ordne sie erst jemand anderem zu – erst dann lässt sich der Sammler löschen.` + : `„${item.title}“ wird gelöscht. Der Katalog bleibt unverändert.` + } + renderFields={(item) => ( + <> + + + + + + + {Object.entries(COLLECTOR_COLOR_LABELS).map(([value, label]) => ( + + ))} + + + + + + + + +