MyKindlebox/mykindlebox/server/db/schema.ts

243 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { sql } from 'drizzle-orm';
import { sqliteTable, text, integer, real, check } from 'drizzle-orm/sqlite-core';
export const rabbits = sqliteTable(
'rabbits',
{
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
farm: text('farm'),
sex: text('sex').notNull().default('unknown'),
dob: text('dob'),
weightLbs: real('weight_lbs'),
earNumber: text('ear_number'),
location: text('location'),
status: text('status').notNull().default('active'),
breed: text('breed'),
colorName: text('color_name'),
photoPath: text('photo_path'),
registrationNumber: text('registration_number'),
gcNumber: text('gc_number'),
sireId: integer('sire_id').references((): any => rabbits.id),
damId: integer('dam_id').references((): any => rabbits.id),
// Timeline anchors (see routes/shows.ts and client RabbitTimeline). `dob` is
// the timeline's start; `acquiredDate` is only meaningful when the rabbit was
// added to the herd after birth (bought-in); `deathDate` records when it died
// (independent of the `deceased` status value, which can be set without a date).
acquiredDate: text('acquired_date'),
deathDate: text('death_date'),
// Genetics loci
locusA: text('locus_a'),
locusB: text('locus_b'),
locusC: text('locus_c'),
locusD: text('locus_d'),
locusE: text('locus_e'),
locusEn: text('locus_en'),
locusV: text('locus_v'),
// Chain-authority freeform ancestor text (see memory-bank/architecture.md)
// Generation 1 (parents)
sireNameText: text('sire_name_text'),
damNameText: text('dam_name_text'),
// Generation 2 (grandparents)
paternalGrandsireNameText: text('paternal_grandsire_name_text'),
paternalGranddamNameText: text('paternal_granddam_name_text'),
maternalGrandsireNameText: text('maternal_grandsire_name_text'),
maternalGranddamNameText: text('maternal_granddam_name_text'),
// Generation 3 (great-grandparents)
ppGrandsireNameText: text('pp_grandsire_name_text'),
ppGranddamNameText: text('pp_granddam_name_text'),
pmGrandsireNameText: text('pm_grandsire_name_text'),
pmGranddamNameText: text('pm_granddam_name_text'),
mpGrandsireNameText: text('mp_grandsire_name_text'),
mpGranddamNameText: text('mp_granddam_name_text'),
mmGrandsireNameText: text('mm_grandsire_name_text'),
mmGranddamNameText: text('mm_granddam_name_text'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
updatedAt: text('updated_at').notNull().default(sql`(current_timestamp)`),
},
(table) => [
check('sex_check', sql`${table.sex} in ('buck', 'doe', 'unknown')`),
check(
'status_check',
sql`${table.status} in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')`
),
]
);
export const litters = sqliteTable('litters', {
id: integer('id').primaryKey({ autoIncrement: true }),
sireId: integer('sire_id').references(() => rabbits.id),
damId: integer('dam_id').references(() => rabbits.id),
// A litter record spans the breeding→kindle lifecycle: `breedDate` is set when
// the pairing is recorded; `kindleDate` (nullable until the doe kindles) and
// `kitCount` are filled in later. At least one of the two dates is required —
// enforced in routes/litters.ts, not by the schema.
breedDate: text('breed_date'),
kindleDate: text('kindle_date'),
kitCount: integer('kit_count'),
notes: text('notes'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
});
// Manually-logged events on a rabbit's timeline. The auto-derived events (birth
// from `dob`, breedings/kindles from `litters`) and the column-backed ones
// (acquired/death) are NOT stored here — only `show`/`sale`/`other`. `title` is
// the timeline headline (show name, sale label, etc.); `showClass`/`placement`
// are only meaningful for `type='show'`. A `sale` event also flips the rabbit's
// status to `sold` (done in routes/events.ts). See the rabbit-timeline note.
export const rabbitEvents = sqliteTable(
'rabbit_events',
{
id: integer('id').primaryKey({ autoIncrement: true }),
rabbitId: integer('rabbit_id')
.notNull()
.references(() => rabbits.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
eventDate: text('event_date').notNull(),
title: text('title').notNull(),
notes: text('notes'),
showClass: text('show_class'),
placement: text('placement'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
},
(table) => [check('event_type_check', sql`${table.type} in ('show', 'sale', 'other')`)]
);
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value'),
});
// User accounts (see memory-bank/FEATURE_User_Accounts.md). All users share the
// same herd — nothing else references users; they are logins, not data owners.
// `theme`/`weightUnit` are the per-user preferences: NULL means "inherit the
// global settings-table value" (routes/settings.ts does the merge), so existing
// global values keep applying until a user picks their own.
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
// The UNIQUE index is case-sensitive (drizzle can't emit COLLATE NOCASE);
// case-insensitive lookups and the "Rory" vs "rory" duplicate guard are both
// enforced with lower() comparisons in auth.ts / routes/users.ts instead.
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(), // "scrypt:<salt hex>:<hash hex>" — see auth.ts
theme: text('theme'),
weightUnit: text('weight_unit'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
});
// Persistent sessions (replaces the old in-memory Set): a server restart or
// redeploy no longer signs everyone out. Expired rows are purged lazily during
// session checks. NOTE: the FK cascade is declarative only — the app runs with
// SQLite foreign_keys OFF (better-sqlite3 default), so user deletion removes
// sessions explicitly in routes/users.ts.
export const authSessions = sqliteTable('auth_sessions', {
token: text('token').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
});
// Cached snapshot of the ARBA show-search listing (see routes/shows.ts and
// lib/arba.ts). ARBA sits behind Cloudflare and only updates Mon–Fri, so we
// scrape at most once a day and serve everything else from here. `data` is the
// normalized show array stored as a JSON blob; `fetchedAt` drives staleness.
// Only ever one row — the latest scrape; an insert is paired with a delete of
// older rows in the route.
export const arbaShowsCache = sqliteTable('arba_shows_cache', {
id: integer('id').primaryKey({ autoIncrement: true }),
fetchedAt: text('fetched_at').notNull().default(sql`(current_timestamp)`),
data: text('data').notNull(),
});
// A user-created grouping of one or more ARBA shows (e.g. a weekend away with
// several shows). Single-user app, so there's no user_id (the design doc's
// `user_id`/`users` table doesn't exist here). Judges/entries/placements
// (Phases 3–4) hang off `show_event_shows`, not this table.
export const showEvents = sqliteTable('show_events', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
notes: text('notes'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
updatedAt: text('updated_at').notNull().default(sql`(current_timestamp)`),
});
// A snapshot of one ARBA show as it was when added to an event. We copy the
// fields rather than referencing the cache because the ARBA listing changes
// daily and rolls off once a show passes — the event must keep its own record.
// `show_type` stores the human-readable full string (e.g. "OPEN ALL BREED
// SHOW"); `specialty_breed` is set only for specialty shows.
export const showEventShows = sqliteTable('show_event_shows', {
id: integer('id').primaryKey({ autoIncrement: true }),
showEventId: integer('show_event_id')
.notNull()
.references(() => showEvents.id, { onDelete: 'cascade' }),
sanctionNumber: text('sanction_number').notNull(),
showDate: text('show_date'),
clubName: text('club_name').notNull(),
showType: text('show_type'),
specialtyBreed: text('specialty_breed'),
city: text('city'),
state: text('state'),
address: text('address'),
secretary: text('secretary'),
secretaryEmail: text('secretary_email'),
catalogUrl: text('catalog_url'),
mapsUrl: text('maps_url'),
displayOrder: integer('display_order').default(0),
});
// A rabbit from the herd entered into a specific show within an event.
// `entry_class` is free text (e.g. "Sr Buck", "6-8 Doe"). Cascades when its
// show is removed; the rabbit FK has no cascade (deleting a rabbit shouldn't
// silently drop show history — handled in routes/rabbits.ts's delete if needed).
// Placements (Phase 4) will reference these rows.
export const showEventEntries = sqliteTable('show_event_entries', {
id: integer('id').primaryKey({ autoIncrement: true }),
showEventShowId: integer('show_event_show_id')
.notNull()
.references(() => showEventShows.id, { onDelete: 'cascade' }),
rabbitId: integer('rabbit_id')
.notNull()
.references(() => rabbits.id),
entryClass: text('entry_class'),
entryNumber: text('entry_number'),
notes: text('notes'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
});
// Judges assigned to a show within an event (Phase 4). Cascades with its show.
export const showEventJudges = sqliteTable('show_event_judges', {
id: integer('id').primaryKey({ autoIncrement: true }),
showEventShowId: integer('show_event_show_id')
.notNull()
.references(() => showEventShows.id, { onDelete: 'cascade' }),
judgeName: text('judge_name').notNull(),
judgeArbaNumber: text('judge_arba_number'),
notes: text('notes'),
});
// Post-show placement for a single entry (Phase 4). One row per entry (the
// `entry_id` UNIQUE makes the PUT endpoint a clean upsert). `placement` is free
// text (`1st`, `BOB`, `BOSV`, …); `legs_earned` is the leg count (0–3); the four
// booleans are quick toggles for the Results Summary rollup.
export const showEventPlacements = sqliteTable('show_event_placements', {
id: integer('id').primaryKey({ autoIncrement: true }),
entryId: integer('entry_id')
.notNull()
.unique()
.references(() => showEventEntries.id, { onDelete: 'cascade' }),
placement: text('placement'),
legsEarned: integer('legs_earned').notNull().default(0),
bob: integer('bob', { mode: 'boolean' }).notNull().default(false),
bos: integer('bos', { mode: 'boolean' }).notNull().default(false),
bis: integer('bis', { mode: 'boolean' }).notNull().default(false),
ris: integer('ris', { mode: 'boolean' }).notNull().default(false),
notes: text('notes'),
});