MyKindlebox/mykindlebox/server/db/schema.ts

212 lines
9.2 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'),
});
// 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'),
});