feat(auth): better-auth mit passwort, passkeys und app-shell
Registrierung des ersten Nutzers macht ihn zum Administrator, danach ist die Registrierung geschlossen (Einladungen folgen in Phase 2). Login per Passwort oder Passkey inklusive Conditional UI, Verwaltung der Passkeys unter "Konto" mit Umbenennen und Loeschen. Rate-Limiting liegt in der Datenbank und ueberlebt Neustarts; Session-Cookies sind httpOnly, sameSite=lax und im Produktivbetrieb secure. Die Client-IP wird hinter dem Reverse Proxy aus x-forwarded-for gelesen. Dazu die mobile-first Grundstruktur: Bottom-Navigation, Header mit Konto-Menue, Healthcheck unter /api/health und ein Dashboard mit den vier Einstiegspunkten. Bedienelemente sind auf 44px Touch-Groesse angehoben. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-33
@@ -3,23 +3,23 @@ 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.
|
||||
* The drizzle adapter resolves models through the TypeScript property names
|
||||
* (`userId`, `expiresAt`, ...), so the column names below are free to follow
|
||||
* the snake_case convention used by the rest of the schema.
|
||||
*/
|
||||
|
||||
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),
|
||||
emailVerified: integer('email_verified', { 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(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const session = sqliteTable(
|
||||
@@ -27,14 +27,14 @@ export const session = sqliteTable(
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
token: text('token').notNull().unique(),
|
||||
userId: text('userId')
|
||||
userId: text('user_id')
|
||||
.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(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
},
|
||||
(t) => [index('session_user_idx').on(t.userId)],
|
||||
);
|
||||
@@ -44,20 +44,20 @@ export const account = sqliteTable(
|
||||
'account',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('accountId').notNull(),
|
||||
providerId: text('providerId').notNull(),
|
||||
userId: text('userId')
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.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' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
|
||||
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
},
|
||||
(t) => [index('account_user_idx').on(t.userId)],
|
||||
);
|
||||
@@ -68,9 +68,9 @@ export const verification = sqliteTable(
|
||||
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(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
},
|
||||
(t) => [index('verification_identifier_idx').on(t.identifier)],
|
||||
);
|
||||
@@ -81,27 +81,27 @@ export const passkey = sqliteTable(
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name'),
|
||||
publicKey: text('publicKey').notNull(),
|
||||
userId: text('userId')
|
||||
publicKey: text('public_key').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
credentialID: text('credentialID').notNull().unique(),
|
||||
credentialID: text('credential_id').notNull().unique(),
|
||||
counter: integer('counter').notNull(),
|
||||
deviceType: text('deviceType').notNull(),
|
||||
backedUp: integer('backedUp', { mode: 'boolean' }).notNull(),
|
||||
deviceType: text('device_type').notNull(),
|
||||
backedUp: integer('backed_up', { mode: 'boolean' }).notNull(),
|
||||
transports: text('transports'),
|
||||
aaguid: text('aaguid'),
|
||||
createdAt: integer('createdAt', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { 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', {
|
||||
export const rateLimit = sqliteTable('rate_limit', {
|
||||
id: text('id').primaryKey(),
|
||||
key: text('key'),
|
||||
count: integer('count'),
|
||||
lastRequest: integer('lastRequest'),
|
||||
lastRequest: integer('last_request'),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+128
-127
@@ -1,127 +1,128 @@
|
||||
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)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
type AnySQLiteColumn,
|
||||
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((): AnySQLiteColumn => 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)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+102
-95
@@ -1,95 +1,102 @@
|
||||
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)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
type AnySQLiteColumn,
|
||||
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((): AnySQLiteColumn => 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)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user