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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788726280857,
|
||||||
|
"tag": "0000_clean_sumo",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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<typeof createConnection>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const db = globalForDb.bricktrackDb ?? createConnection();
|
||||||
|
|
||||||
|
if (!env.isProduction) {
|
||||||
|
globalForDb.bricktrackDb = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { schema };
|
||||||
|
export type Db = typeof db;
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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<Record<string, string>>(),
|
||||||
|
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)`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -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)`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './auth';
|
||||||
|
export * from './catalog';
|
||||||
|
export * from './collection';
|
||||||
|
export * from './wishlist';
|
||||||
|
export * from './relations';
|
||||||
@@ -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] }),
|
||||||
|
}));
|
||||||
@@ -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`),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -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<CollectionItem['condition']>;
|
||||||
|
export type Occasion = Wishlist['occasion'];
|
||||||
|
export type UserRole = User['role'];
|
||||||
+72
@@ -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;
|
||||||
Reference in New Issue
Block a user