MyKindlebox/mykindlebox/server/lib/ancestors.ts

345 lines
13 KiB
TypeScript

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<string, TextKey> = {
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<string>(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<string, unknown> = { 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<typeof import('../db/client').db, 'select' | 'insert' | 'update'>;
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<RabbitInsert>): string[] {
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(patch)) {
if (isEmpty(value)) continue;
const current = (row as Record<string, unknown>)[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<RabbitInsert> {
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<string, unknown> = {};
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<RabbitInsert>);
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;
}