From 06268bcfd618945f7a45003846e910cdbff944a5 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 22:25:22 +0200 Subject: [PATCH] feat(db): drizzle-schema, db-client und initiale migration Vollstaendiges Datenmodell: Katalog (manufacturer, theme, set, tag, instruction), Besitz (collection_item, location, missing_part, photo), Wunschlisten (wishlist, wishlist_item) und die Auth-Tabellen von better-auth inklusive passkey und invite. Kernregel: ein set ist ein Katalogeintrag, ein collection_item die Verknuepfung mit dem tatsaechlichen Besitz. Preise liegen als Cent in INTEGER-Spalten, damit Summen exakt bleiben. SQLite laeuft mit WAL, foreign_keys=ON und busy_timeout. Co-Authored-By: Claude Opus 5 (1M context) --- drizzle.config.ts | 20 + drizzle/0000_clean_sumo.sql | 271 +++++ drizzle/meta/0000_snapshot.json | 1868 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 13 + scripts/migrate.ts | 37 + src/db/index.ts | 41 + src/db/schema/auth.ts | 126 +++ src/db/schema/catalog.ts | 127 +++ src/db/schema/collection.ts | 95 ++ src/db/schema/index.ts | 5 + src/db/schema/relations.ts | 109 ++ src/db/schema/wishlist.ts | 60 + src/db/types.ts | 35 + src/env.ts | 72 ++ 14 files changed, 2879 insertions(+) create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_clean_sumo.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 scripts/migrate.ts create mode 100644 src/db/index.ts create mode 100644 src/db/schema/auth.ts create mode 100644 src/db/schema/catalog.ts create mode 100644 src/db/schema/collection.ts create mode 100644 src/db/schema/index.ts create mode 100644 src/db/schema/relations.ts create mode 100644 src/db/schema/wishlist.ts create mode 100644 src/db/types.ts create mode 100644 src/env.ts diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..886a1f0 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,20 @@ +import path from 'node:path'; +import { config as loadEnv } from 'dotenv'; +import { defineConfig } from 'drizzle-kit'; + +loadEnv({ path: '.env', quiet: true }); + +// drizzle-kit runs outside the app, so it reads the raw env instead of src/env +// (which would demand runtime-only variables such as BETTER_AUTH_SECRET). +const dataDir = path.resolve(process.env.DATA_DIR ?? './data'); + +export default defineConfig({ + dialect: 'sqlite', + schema: './src/db/schema/index.ts', + out: './drizzle', + dbCredentials: { + url: path.join(dataDir, 'db', 'bricktrack.db'), + }, + strict: true, + verbose: true, +}); diff --git a/drizzle/0000_clean_sumo.sql b/drizzle/0000_clean_sumo.sql new file mode 100644 index 0000000..c1d86bf --- /dev/null +++ b/drizzle/0000_clean_sumo.sql @@ -0,0 +1,271 @@ +CREATE TABLE `account` ( + `id` text PRIMARY KEY NOT NULL, + `accountId` text NOT NULL, + `providerId` text NOT NULL, + `userId` text NOT NULL, + `accessToken` text, + `refreshToken` text, + `idToken` text, + `accessTokenExpiresAt` integer, + `refreshTokenExpiresAt` integer, + `scope` text, + `password` text, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `account_user_idx` ON `account` (`userId`);--> statement-breakpoint +CREATE TABLE `invite` ( + `id` text PRIMARY KEY NOT NULL, + `token_hash` text NOT NULL, + `email` text, + `role` text DEFAULT 'member' NOT NULL, + `created_by_user_id` text NOT NULL, + `expires_at` integer NOT NULL, + `used_at` integer, + `used_by_user_id` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`created_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`used_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `invite_token_hash_unique` ON `invite` (`token_hash`);--> statement-breakpoint +CREATE TABLE `passkey` ( + `id` text PRIMARY KEY NOT NULL, + `name` text, + `publicKey` text NOT NULL, + `userId` text NOT NULL, + `credentialID` text NOT NULL, + `counter` integer NOT NULL, + `deviceType` text NOT NULL, + `backedUp` integer NOT NULL, + `transports` text, + `aaguid` text, + `createdAt` integer, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `passkey_credentialID_unique` ON `passkey` (`credentialID`);--> statement-breakpoint +CREATE INDEX `passkey_user_idx` ON `passkey` (`userId`);--> statement-breakpoint +CREATE TABLE `rateLimit` ( + `id` text PRIMARY KEY NOT NULL, + `key` text, + `count` integer, + `lastRequest` integer +); +--> statement-breakpoint +CREATE TABLE `session` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `userId` text NOT NULL, + `expiresAt` integer NOT NULL, + `ipAddress` text, + `userAgent` text, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint +CREATE INDEX `session_user_idx` ON `session` (`userId`);--> statement-breakpoint +CREATE TABLE `user` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `email` text NOT NULL, + `emailVerified` integer DEFAULT false NOT NULL, + `image` text, + `role` text DEFAULT 'member' NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint +CREATE TABLE `verification` ( + `id` text PRIMARY KEY NOT NULL, + `identifier` text NOT NULL, + `value` text NOT NULL, + `expiresAt` integer NOT NULL, + `createdAt` integer NOT NULL, + `updatedAt` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint +CREATE TABLE `instruction` ( + `id` text PRIMARY KEY NOT NULL, + `set_id` text NOT NULL, + `kind` text NOT NULL, + `file_path` text, + `url` text, + `label` text, + `page_count` integer, + `file_size_bytes` integer, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "instruction_source" CHECK(("instruction"."kind" = 'pdf_upload' AND "instruction"."file_path" IS NOT NULL AND "instruction"."url" IS NULL) + OR ("instruction"."kind" = 'external_url' AND "instruction"."url" IS NOT NULL AND "instruction"."file_path" IS NULL)) +); +--> statement-breakpoint +CREATE INDEX `instruction_set_idx` ON `instruction` (`set_id`);--> statement-breakpoint +CREATE TABLE `manufacturer` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `website` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `manufacturer_slug_unique` ON `manufacturer` (`slug`);--> statement-breakpoint +CREATE TABLE `set` ( + `id` text PRIMARY KEY NOT NULL, + `manufacturer_id` text NOT NULL, + `theme_id` text, + `set_number` text NOT NULL, + `name` text NOT NULL, + `year` integer, + `piece_count` integer, + `minifig_count` integer, + `ean` text, + `age_range` text, + `image_url` text, + `image_path` text, + `retail_price_cents` integer, + `external_ids` text, + `notes` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`manufacturer_id`) REFERENCES `manufacturer`(`id`) ON UPDATE no action ON DELETE restrict, + FOREIGN KEY (`theme_id`) REFERENCES `theme`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `set_manufacturer_number_unique` ON `set` (`manufacturer_id`,`set_number`);--> statement-breakpoint +CREATE INDEX `set_name_idx` ON `set` (`name`);--> statement-breakpoint +CREATE INDEX `set_ean_idx` ON `set` (`ean`);--> statement-breakpoint +CREATE INDEX `set_theme_idx` ON `set` (`theme_id`);--> statement-breakpoint +CREATE INDEX `set_year_idx` ON `set` (`year`);--> statement-breakpoint +CREATE TABLE `set_tag` ( + `set_id` text NOT NULL, + `tag_id` text NOT NULL, + PRIMARY KEY(`set_id`, `tag_id`), + FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `tag` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tag_slug_unique` ON `tag` (`slug`);--> statement-breakpoint +CREATE TABLE `theme` ( + `id` text PRIMARY KEY NOT NULL, + `manufacturer_id` text NOT NULL, + `parent_theme_id` text, + `name` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`manufacturer_id`) REFERENCES `manufacturer`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`parent_theme_id`) REFERENCES `theme`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `theme_manufacturer_idx` ON `theme` (`manufacturer_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `theme_unique_name` ON `theme` (`manufacturer_id`,`name`);--> statement-breakpoint +CREATE TABLE `collection_item` ( + `id` text PRIMARY KEY NOT NULL, + `set_id` text NOT NULL, + `status` text DEFAULT 'owned' NOT NULL, + `completeness` text DEFAULT 'unknown' NOT NULL, + `condition` text, + `location_id` text, + `acquired_at` integer, + `acquired_price_cents` integer, + `acquired_from` text, + `gifted_by` text, + `lent_out_to` text, + `notes` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`location_id`) REFERENCES `location`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `collection_item_set_idx` ON `collection_item` (`set_id`);--> statement-breakpoint +CREATE INDEX `collection_item_status_idx` ON `collection_item` (`status`);--> statement-breakpoint +CREATE INDEX `collection_item_location_idx` ON `collection_item` (`location_id`);--> statement-breakpoint +CREATE TABLE `location` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `parent_location_id` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`parent_location_id`) REFERENCES `location`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `missing_part` ( + `id` text PRIMARY KEY NOT NULL, + `collection_item_id` text NOT NULL, + `part_number` text, + `color_name` text, + `quantity` integer DEFAULT 1 NOT NULL, + `resolved` integer DEFAULT false NOT NULL, + `notes` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`collection_item_id`) REFERENCES `collection_item`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `missing_part_item_idx` ON `missing_part` (`collection_item_id`);--> statement-breakpoint +CREATE TABLE `photo` ( + `id` text PRIMARY KEY NOT NULL, + `set_id` text, + `collection_item_id` text, + `file_path` text NOT NULL, + `thumbnail_path` text, + `caption` text, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`collection_item_id`) REFERENCES `collection_item`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "photo_single_owner" CHECK(("photo"."set_id" IS NOT NULL) <> ("photo"."collection_item_id" IS NOT NULL)) +); +--> statement-breakpoint +CREATE INDEX `photo_set_idx` ON `photo` (`set_id`);--> statement-breakpoint +CREATE INDEX `photo_item_idx` ON `photo` (`collection_item_id`);--> statement-breakpoint +CREATE TABLE `wishlist` ( + `id` text PRIMARY KEY NOT NULL, + `owner_user_id` text NOT NULL, + `title` text NOT NULL, + `occasion` text DEFAULT 'other' NOT NULL, + `event_date` integer, + `share_token` text NOT NULL, + `is_public` integer DEFAULT false NOT NULL, + `surprise_mode` integer DEFAULT false NOT NULL, + `note` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `wishlist_share_token_unique` ON `wishlist` (`share_token`);--> statement-breakpoint +CREATE INDEX `wishlist_owner_idx` ON `wishlist` (`owner_user_id`);--> statement-breakpoint +CREATE TABLE `wishlist_item` ( + `id` text PRIMARY KEY NOT NULL, + `wishlist_id` text NOT NULL, + `set_id` text NOT NULL, + `priority` integer DEFAULT 2 NOT NULL, + `note` text, + `sort_order` integer DEFAULT 0 NOT NULL, + `reserved_by_name` text, + `reserved_at` integer, + `reservation_token_hash` text, + `fulfilled_at` integer, + `created_at` integer NOT NULL, + FOREIGN KEY (`wishlist_id`) REFERENCES `wishlist`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "wishlist_item_priority" CHECK("wishlist_item"."priority" BETWEEN 1 AND 3) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `wishlist_item_unique` ON `wishlist_item` (`wishlist_id`,`set_id`);--> statement-breakpoint +CREATE INDEX `wishlist_item_list_idx` ON `wishlist_item` (`wishlist_id`,`sort_order`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..17cdbe0 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1868 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5d6a1e8b-5b30-4c01-8044-1db2d04615e0", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "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 + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backedUp": { + "name": "backedUp", + "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 + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_credentialID_unique": { + "name": "passkey_credentialID_unique", + "columns": [ + "credentialID" + ], + "isUnique": true + }, + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_userId_user_id_fk": { + "name": "passkey_userId_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rateLimit": { + "name": "rateLimit", + "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 + }, + "lastRequest": { + "name": "lastRequest", + "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 + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "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": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "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 + }, + "emailVerified": { + "name": "emailVerified", + "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'" + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "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 + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "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": {} + }, + "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 + }, + "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_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_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 + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..5b3d3e7 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1788726280857, + "tag": "0000_clean_sumo", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..71ac017 --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,37 @@ +/** + * Applies pending migrations from ./drizzle to the SQLite file. + * + * Runs on every container start (see docker/entrypoint.sh) and locally via + * `pnpm db:migrate`. Uses the migrator from drizzle-orm, which is part of the + * runtime bundle, so the image does not need drizzle-kit. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { config as loadEnv } from 'dotenv'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; + +loadEnv({ path: '.env', quiet: true }); + +const dataDir = path.resolve(process.env.DATA_DIR ?? './data'); +const dbDir = path.join(dataDir, 'db'); +const dbPath = path.join(dbDir, 'bricktrack.db'); +const migrationsFolder = path.resolve(process.env.MIGRATIONS_DIR ?? './drizzle'); + +fs.mkdirSync(dbDir, { recursive: true }); +fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true }); + +const sqlite = new Database(dbPath); +sqlite.pragma('journal_mode = WAL'); +sqlite.pragma('foreign_keys = ON'); + +const db = drizzle(sqlite); + +console.log(`[migrate] Datenbank: ${dbPath}`); +console.log(`[migrate] Migrationen: ${migrationsFolder}`); + +migrate(db, { migrationsFolder }); + +console.log('[migrate] Fertig.'); +sqlite.close(); diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..4c1ee36 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { env } from '@/env'; +import * as schema from './schema'; + +/** + * SQLite connection for the whole app. + * + * Cached on globalThis so Next's dev-mode module reloading does not open a new + * file handle on every hot reload. + */ +function createConnection() { + fs.mkdirSync(env.DB_DIR, { recursive: true }); + + const sqlite = new Database(env.DB_PATH); + // WAL keeps readers from blocking the single writer - required for a server + // that renders pages while a background action writes. + sqlite.pragma('journal_mode = WAL'); + // NORMAL is the recommended durability level in WAL mode. + sqlite.pragma('synchronous = NORMAL'); + // SQLite has foreign keys off by default; our schema relies on them. + sqlite.pragma('foreign_keys = ON'); + // Wait instead of throwing SQLITE_BUSY when another write is in flight. + sqlite.pragma('busy_timeout = 5000'); + + return drizzle(sqlite, { schema }); +} + +const globalForDb = globalThis as unknown as { + bricktrackDb?: ReturnType; +}; + +export const db = globalForDb.bricktrackDb ?? createConnection(); + +if (!env.isProduction) { + globalForDb.bricktrackDb = db; +} + +export { schema }; +export type Db = typeof db; diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts new file mode 100644 index 0000000..b0443c6 --- /dev/null +++ b/src/db/schema/auth.ts @@ -0,0 +1,126 @@ +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * Tables owned by better-auth. + * + * Column names are camelCase here (unlike the rest of the schema) because that + * is exactly what `@better-auth/cli generate` produces. Renaming them is a + * reliable source of breakage whenever a plugin is added or updated. + */ + +export const user = sqliteTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: integer('emailVerified', { mode: 'boolean' }).notNull().default(false), + image: text('image'), + /** App specific: the first registered user becomes admin, everyone else member. */ + role: text('role', { enum: ['admin', 'member'] }) + .notNull() + .default('member'), + createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), +}); + +export const session = sqliteTable( + 'session', + { + id: text('id').primaryKey(), + token: text('token').notNull().unique(), + userId: text('userId') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), + ipAddress: text('ipAddress'), + userAgent: text('userAgent'), + createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('session_user_idx').on(t.userId)], +); + +/** Credential provider only for now; `password` holds the hashed password. */ +export const account = sqliteTable( + 'account', + { + id: text('id').primaryKey(), + accountId: text('accountId').notNull(), + providerId: text('providerId').notNull(), + userId: text('userId') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('accessToken'), + refreshToken: text('refreshToken'), + idToken: text('idToken'), + accessTokenExpiresAt: integer('accessTokenExpiresAt', { mode: 'timestamp' }), + refreshTokenExpiresAt: integer('refreshTokenExpiresAt', { mode: 'timestamp' }), + scope: text('scope'), + password: text('password'), + createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('account_user_idx').on(t.userId)], +); + +export const verification = sqliteTable( + 'verification', + { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), + createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('verification_identifier_idx').on(t.identifier)], +); + +/** @better-auth/passkey - a user may register any number of passkeys. */ +export const passkey = sqliteTable( + 'passkey', + { + id: text('id').primaryKey(), + name: text('name'), + publicKey: text('publicKey').notNull(), + userId: text('userId') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + credentialID: text('credentialID').notNull().unique(), + counter: integer('counter').notNull(), + deviceType: text('deviceType').notNull(), + backedUp: integer('backedUp', { mode: 'boolean' }).notNull(), + transports: text('transports'), + aaguid: text('aaguid'), + createdAt: integer('createdAt', { mode: 'timestamp' }), + }, + (t) => [index('passkey_user_idx').on(t.userId)], +); + +/** Persistent rate limiting so restarts do not reset login throttling. */ +export const rateLimit = sqliteTable('rateLimit', { + id: text('id').primaryKey(), + key: text('key'), + count: integer('count'), + lastRequest: integer('lastRequest'), +}); + +/** + * Self-built invitations: after the first user, registration is only possible + * through a link an admin created. Used from phase 2 on. + */ +export const invite = sqliteTable('invite', { + id: text('id').primaryKey(), + /** SHA-256 of the link token - the plaintext only ever lives in the link. */ + tokenHash: text('token_hash').notNull().unique(), + email: text('email'), + role: text('role', { enum: ['admin', 'member'] }) + .notNull() + .default('member'), + createdByUserId: text('created_by_user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + usedAt: integer('used_at', { mode: 'timestamp' }), + usedByUserId: text('used_by_user_id').references(() => user.id, { onDelete: 'set null' }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), +}); diff --git a/src/db/schema/catalog.ts b/src/db/schema/catalog.ts new file mode 100644 index 0000000..6194846 --- /dev/null +++ b/src/db/schema/catalog.ts @@ -0,0 +1,127 @@ +import { sql, type AnyColumn } from 'drizzle-orm'; +import { + check, + index, + integer, + primaryKey, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; + +export const manufacturer = sqliteTable('manufacturer', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + website: text('website'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const theme = sqliteTable( + 'theme', + { + id: text('id').primaryKey(), + manufacturerId: text('manufacturer_id') + .notNull() + .references(() => manufacturer.id, { onDelete: 'cascade' }), + parentThemeId: text('parent_theme_id').references((): AnyColumn => theme.id, { + onDelete: 'set null', + }), + name: text('name').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('theme_manufacturer_idx').on(t.manufacturerId), + uniqueIndex('theme_unique_name').on(t.manufacturerId, t.name), + ], +); + +/** + * A catalog entry. The set exists in the world, independent of whether we own + * it - ownership is expressed by `collection_item`. + */ +export const set = sqliteTable( + 'set', + { + id: text('id').primaryKey(), + manufacturerId: text('manufacturer_id') + .notNull() + .references(() => manufacturer.id, { onDelete: 'restrict' }), + themeId: text('theme_id').references(() => theme.id, { onDelete: 'set null' }), + /** Manufacturer's own number, e.g. "60398", "71427", "2-30500". */ + setNumber: text('set_number').notNull(), + name: text('name').notNull(), + year: integer('year'), + pieceCount: integer('piece_count'), + minifigCount: integer('minifig_count'), + ean: text('ean'), + ageRange: text('age_range'), + /** Either an external URL the user entered ... */ + imageUrl: text('image_url'), + /** ... or a locally stored file below FILES_DIR. */ + imagePath: text('image_path'), + /** EUR in cents - SQLite has no decimal type and sums must stay exact. */ + retailPriceCents: integer('retail_price_cents'), + externalIds: text('external_ids', { mode: 'json' }).$type>(), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + uniqueIndex('set_manufacturer_number_unique').on(t.manufacturerId, t.setNumber), + index('set_name_idx').on(t.name), + index('set_ean_idx').on(t.ean), + index('set_theme_idx').on(t.themeId), + index('set_year_idx').on(t.year), + ], +); + +export const tag = sqliteTable('tag', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), +}); + +export const setTag = sqliteTable( + 'set_tag', + { + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + tagId: text('tag_id') + .notNull() + .references(() => tag.id, { onDelete: 'cascade' }), + }, + (t) => [primaryKey({ columns: [t.setId, t.tagId] })], +); + +export const instruction = sqliteTable( + 'instruction', + { + id: text('id').primaryKey(), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + kind: text('kind', { enum: ['pdf_upload', 'external_url'] }).notNull(), + /** Set when kind = pdf_upload. */ + filePath: text('file_path'), + /** Set when kind = external_url. */ + url: text('url'), + /** Free text label, e.g. "Buch 1 von 3". */ + label: text('label'), + pageCount: integer('page_count'), + fileSizeBytes: integer('file_size_bytes'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('instruction_set_idx').on(t.setId), + check( + 'instruction_source', + sql`(${t.kind} = 'pdf_upload' AND ${t.filePath} IS NOT NULL AND ${t.url} IS NULL) + OR (${t.kind} = 'external_url' AND ${t.url} IS NOT NULL AND ${t.filePath} IS NULL)`, + ), + ], +); diff --git a/src/db/schema/collection.ts b/src/db/schema/collection.ts new file mode 100644 index 0000000..503f69f --- /dev/null +++ b/src/db/schema/collection.ts @@ -0,0 +1,95 @@ +import { sql, type AnyColumn } from 'drizzle-orm'; +import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { set } from './catalog'; + +export const location = sqliteTable('location', { + id: text('id').primaryKey(), + /** e.g. "Regal Kinderzimmer", "Kiste Keller 3". */ + name: text('name').notNull(), + parentLocationId: text('parent_location_id').references((): AnyColumn => location.id, { + onDelete: 'set null', + }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +/** + * Ownership: links a catalog set to a copy we actually have. Owning the same + * set twice means two rows. + */ +export const collectionItem = sqliteTable( + 'collection_item', + { + id: text('id').primaryKey(), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'], + }) + .notNull() + .default('owned'), + completeness: text('completeness', { enum: ['complete', 'missing_parts', 'unknown'] }) + .notNull() + .default('unknown'), + condition: text('condition', { + enum: ['new_sealed', 'like_new', 'good', 'used', 'damaged'], + }), + locationId: text('location_id').references(() => location.id, { onDelete: 'set null' }), + acquiredAt: integer('acquired_at', { mode: 'timestamp' }), + acquiredPriceCents: integer('acquired_price_cents'), + acquiredFrom: text('acquired_from'), + giftedBy: text('gifted_by'), + lentOutTo: text('lent_out_to'), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('collection_item_set_idx').on(t.setId), + index('collection_item_status_idx').on(t.status), + index('collection_item_location_idx').on(t.locationId), + ], +); + +export const missingPart = sqliteTable( + 'missing_part', + { + id: text('id').primaryKey(), + collectionItemId: text('collection_item_id') + .notNull() + .references(() => collectionItem.id, { onDelete: 'cascade' }), + partNumber: text('part_number'), + colorName: text('color_name'), + quantity: integer('quantity').notNull().default(1), + resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('missing_part_item_idx').on(t.collectionItemId)], +); + +/** Belongs to either a catalog set or one owned copy - never to both. */ +export const photo = sqliteTable( + 'photo', + { + id: text('id').primaryKey(), + setId: text('set_id').references(() => set.id, { onDelete: 'cascade' }), + collectionItemId: text('collection_item_id').references(() => collectionItem.id, { + onDelete: 'cascade', + }), + filePath: text('file_path').notNull(), + thumbnailPath: text('thumbnail_path'), + caption: text('caption'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('photo_set_idx').on(t.setId), + index('photo_item_idx').on(t.collectionItemId), + check( + 'photo_single_owner', + sql`(${t.setId} IS NOT NULL) <> (${t.collectionItemId} IS NOT NULL)`, + ), + ], +); diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts new file mode 100644 index 0000000..4103838 --- /dev/null +++ b/src/db/schema/index.ts @@ -0,0 +1,5 @@ +export * from './auth'; +export * from './catalog'; +export * from './collection'; +export * from './wishlist'; +export * from './relations'; diff --git a/src/db/schema/relations.ts b/src/db/schema/relations.ts new file mode 100644 index 0000000..68a4737 --- /dev/null +++ b/src/db/schema/relations.ts @@ -0,0 +1,109 @@ +import { relations } from 'drizzle-orm'; +import { passkey, session, user } from './auth'; +import { instruction, manufacturer, set, setTag, tag, theme } from './catalog'; +import { collectionItem, location, missingPart, photo } from './collection'; +import { wishlist, wishlistItem } from './wishlist'; + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + passkeys: many(passkey), + wishlists: many(wishlist), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { fields: [session.userId], references: [user.id] }), +})); + +export const passkeyRelations = relations(passkey, ({ one }) => ({ + user: one(user, { fields: [passkey.userId], references: [user.id] }), +})); + +export const manufacturerRelations = relations(manufacturer, ({ many }) => ({ + themes: many(theme), + sets: many(set), +})); + +export const themeRelations = relations(theme, ({ one, many }) => ({ + manufacturer: one(manufacturer, { + fields: [theme.manufacturerId], + references: [manufacturer.id], + }), + parent: one(theme, { + fields: [theme.parentThemeId], + references: [theme.id], + relationName: 'themeHierarchy', + }), + children: many(theme, { relationName: 'themeHierarchy' }), + sets: many(set), +})); + +export const setRelations = relations(set, ({ one, many }) => ({ + manufacturer: one(manufacturer, { + fields: [set.manufacturerId], + references: [manufacturer.id], + }), + theme: one(theme, { fields: [set.themeId], references: [theme.id] }), + collectionItems: many(collectionItem), + instructions: many(instruction), + photos: many(photo), + setTags: many(setTag), + wishlistItems: many(wishlistItem), +})); + +export const tagRelations = relations(tag, ({ many }) => ({ + setTags: many(setTag), +})); + +export const setTagRelations = relations(setTag, ({ one }) => ({ + set: one(set, { fields: [setTag.setId], references: [set.id] }), + tag: one(tag, { fields: [setTag.tagId], references: [tag.id] }), +})); + +export const instructionRelations = relations(instruction, ({ one }) => ({ + set: one(set, { fields: [instruction.setId], references: [set.id] }), +})); + +export const locationRelations = relations(location, ({ one, many }) => ({ + parent: one(location, { + fields: [location.parentLocationId], + references: [location.id], + relationName: 'locationHierarchy', + }), + children: many(location, { relationName: 'locationHierarchy' }), + collectionItems: many(collectionItem), +})); + +export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({ + set: one(set, { fields: [collectionItem.setId], references: [set.id] }), + location: one(location, { + fields: [collectionItem.locationId], + references: [location.id], + }), + missingParts: many(missingPart), + photos: many(photo), +})); + +export const missingPartRelations = relations(missingPart, ({ one }) => ({ + collectionItem: one(collectionItem, { + fields: [missingPart.collectionItemId], + references: [collectionItem.id], + }), +})); + +export const photoRelations = relations(photo, ({ one }) => ({ + set: one(set, { fields: [photo.setId], references: [set.id] }), + collectionItem: one(collectionItem, { + fields: [photo.collectionItemId], + references: [collectionItem.id], + }), +})); + +export const wishlistRelations = relations(wishlist, ({ one, many }) => ({ + owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }), + items: many(wishlistItem), +})); + +export const wishlistItemRelations = relations(wishlistItem, ({ one }) => ({ + wishlist: one(wishlist, { fields: [wishlistItem.wishlistId], references: [wishlist.id] }), + set: one(set, { fields: [wishlistItem.setId], references: [set.id] }), +})); diff --git a/src/db/schema/wishlist.ts b/src/db/schema/wishlist.ts new file mode 100644 index 0000000..456dd61 --- /dev/null +++ b/src/db/schema/wishlist.ts @@ -0,0 +1,60 @@ +import { sql } from 'drizzle-orm'; +import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { user } from './auth'; +import { set } from './catalog'; + +export const wishlist = sqliteTable( + 'wishlist', + { + id: text('id').primaryKey(), + ownerUserId: text('owner_user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + /** e.g. "Weihnachten 2026". */ + title: text('title').notNull(), + occasion: text('occasion', { enum: ['christmas', 'birthday', 'easter', 'other'] }) + .notNull() + .default('other'), + eventDate: integer('event_date', { mode: 'timestamp' }), + /** 32 random bytes, base64url encoded - the only key to the public view. */ + shareToken: text('share_token').notNull().unique(), + isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(false), + /** When on, the owner sees "reserviert" but not by whom. */ + surpriseMode: integer('surprise_mode', { mode: 'boolean' }).notNull().default(false), + /** Message shown to the gift givers, e.g. "Bitte keine Doppelungen". */ + note: text('note'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('wishlist_owner_idx').on(t.ownerUserId)], +); + +export const wishlistItem = sqliteTable( + 'wishlist_item', + { + id: text('id').primaryKey(), + wishlistId: text('wishlist_id') + .notNull() + .references(() => wishlist.id, { onDelete: 'cascade' }), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + /** 1 = highest, 3 = lowest. */ + priority: integer('priority').notNull().default(2), + note: text('note'), + sortOrder: integer('sort_order').notNull().default(0), + reservedByName: text('reserved_by_name'), + reservedAt: integer('reserved_at', { mode: 'timestamp' }), + /** SHA-256 of the cancellation token; plaintext only lives in the link. */ + reservationTokenHash: text('reservation_token_hash'), + /** Set once the set showed up in the collection. */ + fulfilledAt: integer('fulfilled_at', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + // Makes "skip duplicates" a database guarantee, not just a UI rule. + uniqueIndex('wishlist_item_unique').on(t.wishlistId, t.setId), + index('wishlist_item_list_idx').on(t.wishlistId, t.sortOrder), + check('wishlist_item_priority', sql`${t.priority} BETWEEN 1 AND 3`), + ], +); diff --git a/src/db/types.ts b/src/db/types.ts new file mode 100644 index 0000000..e75fd23 --- /dev/null +++ b/src/db/types.ts @@ -0,0 +1,35 @@ +import type { + collectionItem, + instruction, + location, + manufacturer, + missingPart, + photo, + set, + tag, + theme, + user, + wishlist, + wishlistItem, +} from './schema'; + +export type User = typeof user.$inferSelect; +export type Manufacturer = typeof manufacturer.$inferSelect; +export type Theme = typeof theme.$inferSelect; +export type Set = typeof set.$inferSelect; +export type NewSet = typeof set.$inferInsert; +export type Instruction = typeof instruction.$inferSelect; +export type Location = typeof location.$inferSelect; +export type CollectionItem = typeof collectionItem.$inferSelect; +export type NewCollectionItem = typeof collectionItem.$inferInsert; +export type MissingPart = typeof missingPart.$inferSelect; +export type Photo = typeof photo.$inferSelect; +export type Tag = typeof tag.$inferSelect; +export type Wishlist = typeof wishlist.$inferSelect; +export type WishlistItem = typeof wishlistItem.$inferSelect; + +export type CollectionStatus = CollectionItem['status']; +export type Completeness = CollectionItem['completeness']; +export type Condition = NonNullable; +export type Occasion = Wishlist['occasion']; +export type UserRole = User['role']; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..daa678b --- /dev/null +++ b/src/env.ts @@ -0,0 +1,72 @@ +import path from 'node:path'; +import { z } from 'zod'; + +/** + * Central, validated access to the runtime configuration. + * + * Everything the app needs to know about its environment is derived here so + * that a misconfigured container fails fast and loudly instead of misbehaving + * at request time. Only imported from server-side code and scripts. + */ +const schema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + + /** + * Public origin the app is reached under, e.g. https://bricktrack.example.de. + * TLS is terminated by the reverse proxy; this value is what the browser sees + * and therefore defines the WebAuthn relying party ID. + */ + BASE_URL: z.url().default('http://localhost:3000'), + + /** Signing secret for sessions and tokens. */ + BETTER_AUTH_SECRET: z.string().min(32, 'BETTER_AUTH_SECRET braucht mindestens 32 Zeichen'), + + /** Single directory that holds everything worth backing up. */ + DATA_DIR: z.string().min(1).default('./data'), + + /** Upload size limit per file, in megabytes. */ + MAX_UPLOAD_MB: z.coerce.number().int().positive().max(200).default(25), + + /** Optional, phase 2: Rebrickable set import. Import stays disabled without it. */ + REBRICKABLE_API_KEY: z.string().min(1).optional(), +}); + +function parseEnv() { + const parsed = schema.safeParse(process.env); + if (!parsed.success) { + const issues = parsed.error.issues + .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + throw new Error(`Ungueltige Umgebungsvariablen:\n${issues}`); + } + return parsed.data; +} + +const raw = parseEnv(); + +const dataDir = path.resolve(raw.DATA_DIR); +const baseUrl = new URL(raw.BASE_URL); + +export const env = { + ...raw, + BASE_URL: baseUrl.origin, + + /** Absolute paths derived from the single data directory. */ + DATA_DIR: dataDir, + DB_DIR: path.join(dataDir, 'db'), + DB_PATH: path.join(dataDir, 'db', 'bricktrack.db'), + FILES_DIR: path.join(dataDir, 'files'), + + /** + * WebAuthn relying party ID: the registrable domain of BASE_URL. Passkeys are + * bound to it, so changing the domain invalidates existing credentials. + */ + RP_ID: baseUrl.hostname, + RP_NAME: 'Bricktrack', + + MAX_UPLOAD_BYTES: raw.MAX_UPLOAD_MB * 1024 * 1024, + + isProduction: raw.NODE_ENV === 'production', +} as const; + +export type Env = typeof env;