import { and, asc, eq, isNull, or, sql } from 'drizzle-orm'; import { rabbits } from '../db/schema'; // Ancestor materialization and "add again = top up" merging. // // When a rabbit is saved from Add Rabbit, every ancestor entered as text // (typed in or scanned) becomes a real rabbit record linked through // sire_id/dam_id, so it can be reused. Created ancestors get status 'other' // (they show under Archived, not the active herd or dashboard counts). // // Matching (reuse instead of duplicate) — `findExisting`: a rabbit with the // same ear number, else the same name + farm, provided the ear number, sex and // DOB don't contradict each other. When several share an ear number, the // active herd rabbit wins over a reference record. // // Merging never overwrites: a matched rabbit only gets its *empty* fields // filled, missing parents linked/created, and the same applied up the tree. // If a matched rabbit already has a parent that is a different animal from the // one entered, the existing record wins and that branch is reported as a // conflict. type RabbitRow = typeof rabbits.$inferSelect; type RabbitInsert = typeof rabbits.$inferInsert; type Direction = 'sire' | 'dam'; type TextKey = | 'sireNameText' | 'damNameText' | 'paternalGrandsireNameText' | 'paternalGranddamNameText' | 'maternalGrandsireNameText' | 'maternalGranddamNameText' | 'ppGrandsireNameText' | 'ppGranddamNameText' | 'pmGrandsireNameText' | 'pmGranddamNameText' | 'mpGrandsireNameText' | 'mpGranddamNameText' | 'mmGrandsireNameText' | 'mmGranddamNameText'; const TEXT_KEY_BY_PATH: Record = { sire: 'sireNameText', dam: 'damNameText', 'sire,sire': 'paternalGrandsireNameText', 'sire,dam': 'paternalGranddamNameText', 'dam,sire': 'maternalGrandsireNameText', 'dam,dam': 'maternalGranddamNameText', 'sire,sire,sire': 'ppGrandsireNameText', 'sire,sire,dam': 'ppGranddamNameText', 'sire,dam,sire': 'pmGrandsireNameText', 'sire,dam,dam': 'pmGranddamNameText', 'dam,sire,sire': 'mpGrandsireNameText', 'dam,sire,dam': 'mpGranddamNameText', 'dam,dam,sire': 'mmGrandsireNameText', 'dam,dam,dam': 'mmGranddamNameText', }; const TEXT_KEYS = new Set(Object.values(TEXT_KEY_BY_PATH)); const MAX_DEPTH = 3; // great-grandparents; their parents aren't captured const DEFAULT_WEIGHT_LBS = 3; // user rule, mirrors client MANUAL_ANCESTOR_DEFAULTS // Subject fields that a merge never fills from the form: identity/links and // status are handled explicitly, the rest are server-managed. const NON_FILLABLE = new Set(['id', 'sireId', 'damId', 'status', 'createdAt', 'updatedAt', 'photoPath']); interface ParsedAncestor { name: string; farm: string | null; earNumber: string | null; weightLbs: number; dob: string | null; } /** What a merge or materialization did, returned to the client as `merged`. */ export interface MergeReport { filled: string[]; ancestorsAdded: number; ancestorsUpdated: number; conflicts: string[]; hadPhoto: boolean; } function emptyReport(): MergeReport { return { filled: [], ancestorsAdded: 0, ancestorsUpdated: 0, conflicts: [], hadPhoto: false }; } function blankToNull(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function isEmpty(value: unknown): boolean { return value === null || value === undefined || (typeof value === 'string' && value.trim() === ''); } function pathLabel(path: Direction[]): string { return path.map((d) => (d === 'sire' ? 'Sire' : 'Dam')).join("'s "); } // Manual ancestors are stored as JSON ({name, farm, ear_number, weight_lbs, // dob}, see client lib/pedigree.js serializeManualAncestor); older entries // may be a plain name. function parseAncestorText(text: string | null | undefined): ParsedAncestor | null { if (!text?.trim()) return null; let raw: Record = { name: text }; try { const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object' && typeof parsed.name === 'string') raw = parsed; } catch { // plain-text legacy entry } const name = blankToNull(raw.name); if (!name) return null; const weight = Number(raw.weight_lbs); const dob = blankToNull(raw.dob); return { name, farm: blankToNull(raw.farm), earNumber: blankToNull(raw.ear_number)?.toUpperCase() ?? null, weightLbs: Number.isFinite(weight) && weight > 0 ? weight : DEFAULT_WEIGHT_LBS, dob: dob && /^\d{4}-\d{2}-\d{2}$/.test(dob) ? dob : null, }; } // Anything with select/insert/update works — the module's `db` or a transaction. type Db = Pick; interface Identity { name: string; farm: string | null; earNumber: string | null; sex?: string | null; dob?: string | null; } function compatible(row: RabbitRow, who: Identity): boolean { const ear = blankToNull(row.earNumber)?.toUpperCase() ?? null; if (ear && who.earNumber && ear !== who.earNumber) return false; if (who.sex && who.sex !== 'unknown' && row.sex !== 'unknown' && row.sex !== who.sex) return false; if (who.dob && row.dob && row.dob !== who.dob) return false; return true; } export function findExisting(tx: Db, who: Identity): RabbitRow | null { if (who.earNumber) { const byEar = tx .select() .from(rabbits) .where(sql`upper(trim(${rabbits.earNumber})) = ${who.earNumber}`) // Prefer a real herd rabbit over a reference record, then the oldest. .orderBy(sql`${rabbits.status} = 'other'`, asc(rabbits.id)) .all() .filter((row) => compatible(row, who)); if (byEar.length > 0) return byEar[0]; } const farmMatch = who.farm ? sql`lower(trim(${rabbits.farm})) = ${who.farm.toLowerCase()}` : or(isNull(rabbits.farm), sql`trim(${rabbits.farm}) = ''`); const byName = tx .select() .from(rabbits) .where(and(sql`lower(trim(${rabbits.name})) = ${who.name.toLowerCase()}`, farmMatch)) .all() .filter((row) => compatible(row, who)); return byName.length === 1 ? byName[0] : null; // several same-name rabbits: don't guess } function fetchRow(tx: Db, id: number): RabbitRow | undefined { return tx.select().from(rabbits).where(eq(rabbits.id, id)).get(); } // Fill only the empty columns of `row` from `patch`; returns the filled keys. function fillMissing(tx: Db, row: RabbitRow, patch: Partial): string[] { const updates: Record = {}; for (const [key, value] of Object.entries(patch)) { if (isEmpty(value)) continue; const current = (row as Record)[key]; const missing = isEmpty(current) || (key === 'sex' && current === 'unknown' && value !== 'unknown'); if (missing) updates[key] = value; } const keys = Object.keys(updates); if (keys.length > 0) { tx.update(rabbits) .set({ ...updates, updatedAt: new Date().toISOString() }) .where(eq(rabbits.id, row.id)) .run(); Object.assign(row, updates); } return keys; } function ancestorPatch(a: ParsedAncestor): Partial { return { farm: a.farm, earNumber: a.earNumber, dob: a.dob, weightLbs: a.weightLbs }; } function sexForPath(path: Direction[]): 'buck' | 'doe' { return path[path.length - 1] === 'sire' ? 'buck' : 'doe'; } // Resolve the ancestor entered at `path` to a rabbit id: reuse (and top up) a // matching rabbit, or create one with its own parents. function resolveAncestor(tx: Db, values: RabbitInsert, path: Direction[], report: MergeReport): number | null { const ancestor = parseAncestorText(values[TEXT_KEY_BY_PATH[path.join(',')]]); if (!ancestor) return null; const sex = sexForPath(path); const existing = findExisting(tx, { ...ancestor, sex, dob: ancestor.dob }); if (existing) { if (fillMissing(tx, existing, ancestorPatch(ancestor)).length > 0) report.ancestorsUpdated += 1; attachMissingParents(tx, values, existing, path, report); return existing.id; } // Parents first, so an animal repeated higher up the tree (line breeding) // is created once and then found by findExisting on its second appearance. const deeper = path.length < MAX_DEPTH; const sireId = deeper ? resolveAncestor(tx, values, [...path, 'sire'], report) : null; const damId = deeper ? resolveAncestor(tx, values, [...path, 'dam'], report) : null; const created = tx .insert(rabbits) .values({ name: ancestor.name, farm: ancestor.farm, earNumber: ancestor.earNumber, sex, dob: ancestor.dob, weightLbs: ancestor.weightLbs, status: 'other', sireId, damId, }) .returning({ id: rabbits.id }) .get(); report.ancestorsAdded += 1; return created.id; } // For a rabbit already in the DB sitting at `path` in the entered pedigree, // link/create parents it's missing and top up the ones it has (when they're // the same animal as entered). function attachMissingParents( tx: Db, values: RabbitInsert, row: RabbitRow, path: Direction[], report: MergeReport, ): void { if (path.length >= MAX_DEPTH) return; for (const side of ['sire', 'dam'] as const) { const childPath = [...path, side]; const entered = parseAncestorText(values[TEXT_KEY_BY_PATH[childPath.join(',')]]); if (!entered) continue; const fkKey = side === 'sire' ? 'sireId' : 'damId'; const linkedId = row[fkKey]; if (linkedId == null) { const id = resolveAncestor(tx, values, childPath, report); if (id != null && id !== row.id) { tx.update(rabbits).set({ [fkKey]: id, updatedAt: new Date().toISOString() }).where(eq(rabbits.id, row.id)).run(); row[fkKey] = id; } continue; } const linked = fetchRow(tx, linkedId); if (!linked) continue; const sameAnimal = linked.name.trim().toLowerCase() === entered.name.toLowerCase() || (entered.earNumber != null && blankToNull(linked.earNumber)?.toUpperCase() === entered.earNumber); if (!sameAnimal) { report.conflicts.push(`${pathLabel(childPath)}: kept ${linked.name} (you entered ${entered.name})`); continue; } if (fillMissing(tx, linked, ancestorPatch(entered)).length > 0) report.ancestorsUpdated += 1; attachMissingParents(tx, values, linked, childPath, report); } } // New rabbit: replace each parent branch entered as text with a sire_id/dam_id // link and clear that branch's now-redundant text columns. Mutates `values`. // A parent left Unknown keeps any grandparent text as-is. export function materializeAncestors(tx: Db, values: RabbitInsert): MergeReport { const report = emptyReport(); for (const side of ['sire', 'dam'] as const) { const fkKey = side === 'sire' ? 'sireId' : 'damId'; if (values[fkKey] != null) continue; // already linked to a herd rabbit const id = resolveAncestor(tx, values, [side], report); if (id == null) continue; values[fkKey] = id; for (const [pathKey, textKey] of Object.entries(TEXT_KEY_BY_PATH)) { if (pathKey === side || pathKey.startsWith(`${side},`)) values[textKey] = null; } } return report; } /** The existing rabbit an Add Rabbit submission refers to, if any. */ export function findExistingSubject(tx: Db, values: RabbitInsert): RabbitRow | null { const name = blankToNull(values.name); if (!name) return null; return findExisting(tx, { name, farm: blankToNull(values.farm), earNumber: blankToNull(values.earNumber)?.toUpperCase() ?? null, sex: values.sex ?? null, dob: blankToNull(values.dob), }); } // "Add again" on an existing rabbit: fill its empty fields, adopt the entered // status if it was only a reference ancestor, link a parent picked from the // herd if it had none, and add/top up ancestors up the tree. export function mergeIntoExisting(tx: Db, row: RabbitRow, values: RabbitInsert): MergeReport { const report = emptyReport(); report.hadPhoto = Boolean(row.photoPath); const patch: Record = {}; for (const [key, value] of Object.entries(values)) { if (!NON_FILLABLE.has(key) && !TEXT_KEYS.has(key)) patch[key] = value; } if (typeof patch.earNumber === 'string') patch.earNumber = patch.earNumber.trim().toUpperCase(); report.filled = fillMissing(tx, row, patch as Partial); if (row.status === 'other' && values.status && values.status !== 'other') { tx.update(rabbits).set({ status: values.status }).where(eq(rabbits.id, row.id)).run(); row.status = values.status; report.filled.push('status'); } for (const side of ['sire', 'dam'] as const) { const fkKey = side === 'sire' ? 'sireId' : 'damId'; const picked = values[fkKey]; if (picked != null && row[fkKey] == null && picked !== row.id) { tx.update(rabbits).set({ [fkKey]: picked }).where(eq(rabbits.id, row.id)).run(); row[fkKey] = picked; report.filled.push(fkKey); } } attachMissingParents(tx, values, row, [], report); return report; }