345 lines
13 KiB
TypeScript
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;
|
|
}
|