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