From c23b7545151f867b05367bdf7537239f82609f5b Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 09:00:35 +0200
Subject: [PATCH 1/5] feat(sammler): mehrere sammler mit eigener sammlung und
wunschliste
Der Katalog bleibt gemeinsam - ein Set existiert einmal, unabhaengig
davon, wer es hat. Besitz und Wunschlisten gehoeren dagegen je einem
Sammler. Damit beantwortet "hat er das schon?" jetzt die Frage "hat *er*
das schon?": ein Set, das nur die Schwester besitzt, faellt fuer den
Bruder wieder unter "fehlt noch" und darf auf seine Wunschliste.
Wichtig fuer den Alltag: Solange es nur einen Sammler gibt, taucht die
Unterscheidung nirgends in der Oberflaeche auf. Kein Umschalter, keine
Abfrage beim Hinzufuegen, keine Badges - die App bleibt exakt so
einfach wie vorher. Erst der zweite Sammler schaltet all das frei.
Die Besitzlogik liegt weiterhin an genau einer Stelle
(src/lib/ownership.ts) und hat nur eine Dimension dazubekommen. Das
automatische "erfuellt" auf Wunschlisten fragt jetzt ebenfalls je Liste:
ein Wunsch auf Max' Liste ist erfuellt, wenn Max das Set bekommt - nicht
wenn Mia es hat. Listen ohne Sammler verhalten sich wie bisher.
Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alles
Vorhandene zu; umbenennen laesst er sich unter Verwaltung -> Sammler.
Der Seed uebernimmt ihn statt einen dritten anzulegen.
Refs Phase 3
Co-Authored-By: Claude Opus 5 (1M context)
---
drizzle/0001_lumpy_bill_hollister.sql | 28 +
drizzle/meta/0001_snapshot.json | 1992 +++++++++++++++++
drizzle/meta/_journal.json | 7 +
scripts/seed.ts | 98 +-
src/app/(app)/katalog/[id]/page.tsx | 31 +-
src/app/(app)/katalog/page.tsx | 17 +-
src/app/(app)/sammlung/actions.ts | 11 +-
src/app/(app)/sammlung/page.tsx | 28 +-
src/app/(app)/verwaltung/sammler/actions.ts | 160 ++
src/app/(app)/verwaltung/sammler/page.tsx | 40 +
src/app/(app)/wunschlisten/[id]/page.tsx | 19 +-
src/app/(app)/wunschlisten/actions.ts | 5 +-
src/app/(app)/wunschlisten/page.tsx | 12 +-
src/app/w/[shareToken]/page.tsx | 2 +-
src/components/katalog/catalog-grid.tsx | 11 +-
src/components/katalog/set-actions.tsx | 56 +-
src/components/layout/collector-filter.tsx | 78 +
.../sammlung/collection-items-section.tsx | 27 +
src/components/verwaltung/collector-list.tsx | 87 +
src/components/verwaltung/verwaltung-nav.tsx | 1 +
.../wunschlisten/wishlist-form-dialog.tsx | 33 +-
.../wunschlisten/wishlist-header-actions.tsx | 15 +-
src/db/schema/collection.ts | 8 +
src/db/schema/collector.ts | 29 +
src/db/schema/index.ts | 1 +
src/db/schema/relations.ts | 14 +
src/db/schema/wishlist.ts | 6 +
src/lib/collector-colors.ts | 40 +
src/lib/ownership.ts | 73 +-
src/lib/queries/catalog.ts | 16 +-
src/lib/queries/collection.ts | 39 +-
src/lib/queries/collectors.ts | 89 +
src/lib/queries/wishlists.ts | 32 +-
src/lib/validation.ts | 19 +
tests/unit/ownership.test.ts | 46 +
35 files changed, 3104 insertions(+), 66 deletions(-)
create mode 100644 drizzle/0001_lumpy_bill_hollister.sql
create mode 100644 drizzle/meta/0001_snapshot.json
create mode 100644 src/app/(app)/verwaltung/sammler/actions.ts
create mode 100644 src/app/(app)/verwaltung/sammler/page.tsx
create mode 100644 src/components/layout/collector-filter.tsx
create mode 100644 src/components/verwaltung/collector-list.tsx
create mode 100644 src/db/schema/collector.ts
create mode 100644 src/lib/collector-colors.ts
create mode 100644 src/lib/queries/collectors.ts
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/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/_journal.json b/drizzle/meta/_journal.json
index 19c4a53..12651b7 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1788726503038,
"tag": "0000_rapid_gargoyle",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1788763704015,
+ "tag": "0001_lumpy_bill_hollister",
+ "breakpoints": true
}
]
}
\ No newline at end of file
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..3215a56 100644
--- a/src/app/(app)/katalog/[id]/page.tsx
+++ b/src/app/(app)/katalog/[id]/page.tsx
@@ -19,23 +19,34 @@ import {
listPhotosForSet,
} 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 +54,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 +129,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 +161,11 @@ export default async function SetDetailPage({ params }: Props) {
In der Sammlung
-
+ ({ id: row.id, name: row.name }))}
+ />
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)/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..e55f12c 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,18 +35,23 @@ 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) },
@@ -56,9 +64,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 +151,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/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/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..9bbfbb6 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);
diff --git a/src/components/katalog/set-actions.tsx b/src/components/katalog/set-actions.tsx
index 3ec9f6d..1a0b777 100644
--- a/src/components/katalog/set-actions.tsx
+++ b/src/components/katalog/set-actions.tsx
@@ -38,32 +38,47 @@ export function SetActions({
setName,
owned,
wishlists,
+ collectors,
}: {
setId: string;
setName: string;
owned: boolean;
wishlists: Array<{ id: string; title: string }>;
+ /** More than one turns "Zur Sammlung" into a question of "for whom". */
+ collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
+ const [collectorOpen, setCollectorOpen] = useState(false);
+ const [collectorId, setCollectorId] = useState(collectors[0]?.id ?? '');
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
- function addToCollection() {
+ function addToCollection(forCollectorId?: string) {
startTransition(async () => {
- const result = await addSetToCollection(setId);
+ const result = await addSetToCollection(setId, forCollectorId);
if (!result.ok) {
toast.error(result.error);
return;
}
- toast.success('Zur Sammlung hinzugefügt.');
+ const owner = collectors.find((entry) => entry.id === forCollectorId);
+ toast.success(
+ owner ? `Zur Sammlung von ${owner.name} hinzugefügt.` : 'Zur Sammlung hinzugefügt.',
+ );
+ setCollectorOpen(false);
router.refresh();
});
}
+ // With a single collector there is nothing to ask.
+ function onAddClick() {
+ if (collectors.length > 1) setCollectorOpen(true);
+ else addToCollection();
+ }
+
function addToWishlist() {
startTransition(async () => {
let wishlistId = targetId;
@@ -111,7 +126,7 @@ export function SetActions({
return (
-
+
{pending ? : }
Zur Sammlung hinzufügen
@@ -138,6 +153,39 @@ export function SetActions({
) : null}
+
+
+
+ Wer bekommt das Set?
+ {setName}
+
+
+
+ setCollectorId(event.target.value)}
+ >
+ {collectors.map((entry) => (
+
+ {entry.name}
+
+ ))}
+
+
+
+
+ setCollectorOpen(false)} disabled={pending}>
+ Abbrechen
+
+ addToCollection(collectorId)} disabled={pending}>
+ {pending ? : }
+ Hinzufügen
+
+
+
+
+
diff --git a/src/components/layout/collector-filter.tsx b/src/components/layout/collector-filter.tsx
new file mode 100644
index 0000000..bcd284c
--- /dev/null
+++ b/src/components/layout/collector-filter.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { useTransition } from 'react';
+import { cn } from 'cn';
+import { collectorDotClass } from '@/lib/collector-colors';
+
+export type CollectorOption = { id: string; name: string; color: string };
+
+/**
+ * Switches the view between collectors.
+ *
+ * Rendered only when there is more than one - with a single collector the
+ * whole question does not exist, and the app should not ask it. The choice
+ * lives in the URL (`?sammler=`), so a filtered view stays shareable and the
+ * back button behaves.
+ */
+export function CollectorFilter({
+ collectors,
+ allLabel = 'Alle',
+}: {
+ collectors: CollectorOption[];
+ allLabel?: string;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ if (collectors.length < 2) return null;
+
+ const current = searchParams.get('sammler') ?? '';
+
+ function select(id: string) {
+ const params = new URLSearchParams(searchParams.toString());
+ if (id) params.set('sammler', id);
+ else params.delete('sammler');
+ params.delete('seite');
+
+ const query = params.toString();
+ startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
+ }
+
+ const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
+
+ return (
+
+ {options.map((option) => {
+ const active = current === option.id;
+ return (
+ select(option.id)}
+ className={cn(
+ 'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
+ active
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {option.id ? (
+
+ ) : null}
+ {option.name}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/sammlung/collection-items-section.tsx b/src/components/sammlung/collection-items-section.tsx
index 5128f31..adc9af7 100644
--- a/src/components/sammlung/collection-items-section.tsx
+++ b/src/components/sammlung/collection-items-section.tsx
@@ -8,6 +8,7 @@ import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung
import type { MissingPartRow } from '@/lib/queries/collection';
import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
+import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
notes: string | null;
setName: string;
missingParts: MissingPartRow[];
+ collectorId: string | null;
+ collectorName: string | null;
+ collectorColor: string | null;
};
export function CollectionItemsSection({
items,
locations,
+ collectors,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
+ collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
+ {collectors.length > 1 && item.collectorName ? (
+
+ {item.collectorName}
+
+ ) : null}
{statusLabel(item.status)}
@@ -194,6 +205,22 @@ export function CollectionItemsSection({