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