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:
unknown
2026-09-06 22:25:22 +02:00
co-authored by Claude Opus 5
parent 0de49a9f94
commit 06268bcfd6
14 changed files with 2879 additions and 0 deletions
+20
View File
@@ -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,
});
+271
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1788726280857,
"tag": "0000_clean_sumo",
"breakpoints": true
}
]
}
+37
View File
@@ -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();
+41
View File
@@ -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;
+126
View File
@@ -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(),
});
+127
View File
@@ -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)`,
),
],
);
+95
View File
@@ -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)`,
),
],
);
+5
View File
@@ -0,0 +1,5 @@
export * from './auth';
export * from './catalog';
export * from './collection';
export * from './wishlist';
export * from './relations';
+109
View File
@@ -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] }),
}));
+60
View File
@@ -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`),
],
);
+35
View File
@@ -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
View File
@@ -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;