619 lines
19 KiB
TypeScript
619 lines
19 KiB
TypeScript
import { Router } from 'express';
|
|
import { and, or, asc, gte, eq, sql, SQL, AnyColumn, getTableColumns } from 'drizzle-orm';
|
|
import multer from 'multer';
|
|
import path from 'path';
|
|
import { db, uploadsDir } from '../db/client';
|
|
import { rabbits, rabbitEvents, litters, showEventEntries } from '../db/schema';
|
|
import { toCamelCase, toSnakeCase, pick } from '../utils/case';
|
|
import {
|
|
findExistingSubject,
|
|
materializeAncestors,
|
|
mergeIntoExisting,
|
|
MergeReport,
|
|
} from '../lib/ancestors';
|
|
|
|
const router = Router();
|
|
|
|
// Mass-assignment guard: accept only real schema columns from the request body,
|
|
// and treat id / timestamps / photo_path as server-managed (photo is set via the
|
|
// dedicated upload route). Derived from the schema so new columns are covered
|
|
// automatically — anything unexpected in the body is silently dropped.
|
|
const RABBIT_SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'photoPath']);
|
|
const RABBIT_WRITABLE = Object.keys(getTableColumns(rabbits)).filter(
|
|
(key) => !RABBIT_SYSTEM_FIELDS.has(key)
|
|
);
|
|
|
|
const VALID_SEX = new Set(['buck', 'doe', 'unknown']);
|
|
const VALID_STATUS = new Set(['active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other']);
|
|
|
|
// These are also enforced by DB CHECK constraints, but validating here turns a
|
|
// bad value into a clear 400 instead of a generic 500 from the constraint.
|
|
function invalidEnumError(values: Record<string, unknown>): string | null {
|
|
if (values.sex !== undefined && !VALID_SEX.has(values.sex as string)) {
|
|
return `Invalid sex: ${values.sex}`;
|
|
}
|
|
if (values.status !== undefined && !VALID_STATUS.has(values.status as string)) {
|
|
return `Invalid status: ${values.status}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Case-insensitive substring match on name. The user's text is escaped so LIKE
|
|
// wildcards (% and _) and the escape char itself are treated literally — a
|
|
// search for "a_b" matches the string "a_b", not "axb". The ESCAPE clause is
|
|
// required because SQLite's LIKE has no escape character by default.
|
|
function nameContains(term: string): SQL {
|
|
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
return sql`${rabbits.name} like ${`%${escaped}%`} escape '\\'`;
|
|
}
|
|
|
|
// The Herd page's search box: the same escaped substring match, but across
|
|
// name, ear number and color so one box finds a rabbit however you know it.
|
|
function herdTermContains(term: string): SQL {
|
|
const pattern = `%${term.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
return or(
|
|
sql`${rabbits.name} like ${pattern} escape '\\'`,
|
|
sql`${rabbits.earNumber} like ${pattern} escape '\\'`,
|
|
sql`${rabbits.colorName} like ${pattern} escape '\\'`
|
|
)!;
|
|
}
|
|
|
|
// Quick-search "wildcard at the end": a prefix match (`term%`), so typing "Ju"
|
|
// matches "Jim Bean"… no, "Jupiter" — only the start of the field. The user's
|
|
// text is escaped so literal % / _ aren't treated as wildcards. SQLite's LIKE is
|
|
// case-insensitive for ASCII, which is the behavior we want here.
|
|
function fieldStartsWith(column: AnyColumn, term: string): SQL {
|
|
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
|
return sql`${column} like ${`${escaped}%`} escape '\\'`;
|
|
}
|
|
|
|
// The fields the quick-search bar can target, keyed by the snake_case name the
|
|
// client sends in `?fields=` (and the same keys returned in `matched_fields`).
|
|
const QUICK_SEARCH_FIELDS = {
|
|
farm: rabbits.farm,
|
|
name: rabbits.name,
|
|
ear_number: rabbits.earNumber,
|
|
color: rabbits.colorName,
|
|
} as const;
|
|
|
|
type QuickSearchField = keyof typeof QUICK_SEARCH_FIELDS;
|
|
|
|
const ALLOWED_PHOTO_TYPES: Record<string, string> = {
|
|
'image/jpeg': '.jpg',
|
|
'image/png': '.png',
|
|
'image/webp': '.webp',
|
|
};
|
|
|
|
const photoUpload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: uploadsDir,
|
|
filename: (req, file, cb) => {
|
|
cb(null, `${req.params.id}${ALLOWED_PHOTO_TYPES[file.mimetype]}`);
|
|
},
|
|
}),
|
|
fileFilter: (req, file, cb) => {
|
|
if (!ALLOWED_PHOTO_TYPES[file.mimetype]) {
|
|
cb(new Error('INVALID_FILE_TYPE'));
|
|
return;
|
|
}
|
|
cb(null, true);
|
|
},
|
|
}).single('photo');
|
|
|
|
type RabbitRow = typeof rabbits.$inferSelect;
|
|
type Direction = 'sire' | 'dam';
|
|
|
|
interface AncestorNode {
|
|
id: number | null;
|
|
name: string | null;
|
|
farm: string | null;
|
|
sex: string | null;
|
|
ear_number: string | null;
|
|
weight_lbs: number | null;
|
|
dob: string | null;
|
|
color_name: string | null;
|
|
sire_id: number | null;
|
|
dam_id: number | null;
|
|
sire: AncestorNode | null;
|
|
dam: AncestorNode | null;
|
|
}
|
|
|
|
function fetchRabbitById(id: number): RabbitRow | undefined {
|
|
return db.select().from(rabbits).where(eq(rabbits.id, id)).get();
|
|
}
|
|
|
|
// Manual-ancestor JSON for a resolved node (see client lib/pedigree.js).
|
|
// Freeform nodes already hold their stored text.
|
|
function snapshotText(node: AncestorNode | null): string | null {
|
|
if (!node) return null;
|
|
if (node.id == null) return node.name;
|
|
return JSON.stringify({
|
|
name: node.name ?? '',
|
|
farm: node.farm ?? '',
|
|
ear_number: node.ear_number ?? '',
|
|
weight_lbs: node.weight_lbs ?? 3,
|
|
dob: node.dob ?? 'Unknown',
|
|
});
|
|
}
|
|
|
|
function dbAncestorNode(ancestor: RabbitRow): AncestorNode {
|
|
return {
|
|
id: ancestor.id,
|
|
name: ancestor.name,
|
|
farm: ancestor.farm,
|
|
sex: ancestor.sex,
|
|
ear_number: ancestor.earNumber,
|
|
weight_lbs: ancestor.weightLbs,
|
|
dob: ancestor.dob,
|
|
color_name: ancestor.colorName,
|
|
sire_id: ancestor.sireId,
|
|
dam_id: ancestor.damId,
|
|
sire: null,
|
|
dam: null,
|
|
};
|
|
}
|
|
|
|
function freeformAncestorNode(text: string): AncestorNode {
|
|
return {
|
|
id: null,
|
|
name: text,
|
|
farm: null,
|
|
sex: null,
|
|
ear_number: null,
|
|
weight_lbs: null,
|
|
dob: null,
|
|
color_name: null,
|
|
sire_id: null,
|
|
dam_id: null,
|
|
sire: null,
|
|
dam: null,
|
|
};
|
|
}
|
|
|
|
// Chain-authority rule: a flat `_name_text` column is only read when the chain
|
|
// has already broken at an earlier generation (see memory-bank/architecture.md).
|
|
function flatAncestorText(record: RabbitRow, path: Direction[]): string | null {
|
|
switch (path.join(',')) {
|
|
case 'sire,sire':
|
|
return record.paternalGrandsireNameText;
|
|
case 'sire,dam':
|
|
return record.paternalGranddamNameText;
|
|
case 'dam,sire':
|
|
return record.maternalGrandsireNameText;
|
|
case 'dam,dam':
|
|
return record.maternalGranddamNameText;
|
|
case 'sire,sire,sire':
|
|
return record.ppGrandsireNameText;
|
|
case 'sire,sire,dam':
|
|
return record.ppGranddamNameText;
|
|
case 'sire,dam,sire':
|
|
return record.pmGrandsireNameText;
|
|
case 'sire,dam,dam':
|
|
return record.pmGranddamNameText;
|
|
case 'dam,sire,sire':
|
|
return record.mpGrandsireNameText;
|
|
case 'dam,sire,dam':
|
|
return record.mpGranddamNameText;
|
|
case 'dam,dam,sire':
|
|
return record.mmGrandsireNameText;
|
|
case 'dam,dam,dam':
|
|
return record.mmGranddamNameText;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveAncestorNode(record: RabbitRow, path: Direction[]): AncestorNode | null {
|
|
const [head, ...rest] = path;
|
|
const fkValue = head === 'sire' ? record.sireId : record.damId;
|
|
|
|
if (fkValue != null) {
|
|
const ancestor = fetchRabbitById(fkValue);
|
|
if (!ancestor) return null;
|
|
if (rest.length === 0) return dbAncestorNode(ancestor);
|
|
return resolveAncestorNode(ancestor, rest);
|
|
}
|
|
|
|
if (rest.length === 0) {
|
|
const text = head === 'sire' ? record.sireNameText : record.damNameText;
|
|
return text ? freeformAncestorNode(text) : null;
|
|
}
|
|
|
|
const text = flatAncestorText(record, path);
|
|
return text ? freeformAncestorNode(text) : null;
|
|
}
|
|
|
|
const KIT_AGE_WINDOW_DAYS = 56;
|
|
|
|
const sortColumns = {
|
|
name: rabbits.name,
|
|
dob: rabbits.dob,
|
|
weight_lbs: rabbits.weightLbs,
|
|
created_at: rabbits.createdAt,
|
|
} as const;
|
|
|
|
router.get('/', (req, res) => {
|
|
const { sex, status, search, sort } = req.query;
|
|
|
|
const conditions: SQL[] = [];
|
|
|
|
if (sex === 'kits') {
|
|
const cutoff = new Date(Date.now() - KIT_AGE_WINDOW_DAYS * 24 * 60 * 60 * 1000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
conditions.push(gte(rabbits.dob, cutoff));
|
|
} else if (typeof sex === 'string' && sex) {
|
|
conditions.push(eq(rabbits.sex, sex));
|
|
}
|
|
|
|
if (typeof status === 'string' && status) {
|
|
conditions.push(eq(rabbits.status, status));
|
|
}
|
|
|
|
if (typeof search === 'string' && search) {
|
|
conditions.push(herdTermContains(search));
|
|
}
|
|
|
|
const sortColumn =
|
|
typeof sort === 'string' && sort in sortColumns
|
|
? sortColumns[sort as keyof typeof sortColumns]
|
|
: sortColumns.name;
|
|
|
|
const results = db
|
|
.select()
|
|
.from(rabbits)
|
|
.where(conditions.length ? and(...conditions) : undefined)
|
|
.orderBy(asc(sortColumn))
|
|
.all();
|
|
|
|
res.json(results.map(toSnakeCase));
|
|
});
|
|
|
|
router.get('/search', (req, res) => {
|
|
const { q, sex } = req.query;
|
|
|
|
const conditions: SQL[] = [];
|
|
|
|
if (typeof q === 'string' && q) {
|
|
conditions.push(nameContains(q));
|
|
}
|
|
|
|
if (typeof sex === 'string' && sex) {
|
|
conditions.push(eq(rabbits.sex, sex));
|
|
}
|
|
|
|
const results = db
|
|
.select({
|
|
id: rabbits.id,
|
|
name: rabbits.name,
|
|
farm: rabbits.farm,
|
|
sex: rabbits.sex,
|
|
})
|
|
.from(rabbits)
|
|
.where(conditions.length ? and(...conditions) : undefined)
|
|
.orderBy(asc(rabbits.name))
|
|
.limit(10)
|
|
.all();
|
|
|
|
res.json(results.map(toSnakeCase));
|
|
});
|
|
|
|
// Quick-search bar (top of every page): prefix-matches `q` against any of the
|
|
// selected fields (defaults to all), and tags each hit with the field(s) it
|
|
// matched on so the dropdown can show why it surfaced. Must be registered
|
|
// before `GET /:id` so "quick-search" isn't swallowed as an id.
|
|
router.get('/quick-search', (req, res) => {
|
|
const { q, fields } = req.query;
|
|
|
|
const term = typeof q === 'string' ? q.trim() : '';
|
|
if (!term) {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
|
|
// Default to every field; when `fields` is given, keep only known keys.
|
|
const requested: QuickSearchField[] =
|
|
typeof fields === 'string' && fields
|
|
? (fields.split(',').filter((f): f is QuickSearchField => f in QUICK_SEARCH_FIELDS))
|
|
: (Object.keys(QUICK_SEARCH_FIELDS) as QuickSearchField[]);
|
|
|
|
if (requested.length === 0) {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
|
|
const matchers = requested.map((f) => fieldStartsWith(QUICK_SEARCH_FIELDS[f], term));
|
|
|
|
const results = db
|
|
.select({
|
|
id: rabbits.id,
|
|
name: rabbits.name,
|
|
farm: rabbits.farm,
|
|
sex: rabbits.sex,
|
|
earNumber: rabbits.earNumber,
|
|
colorName: rabbits.colorName,
|
|
status: rabbits.status,
|
|
})
|
|
.from(rabbits)
|
|
.where(or(...matchers))
|
|
.orderBy(asc(rabbits.name))
|
|
.limit(10)
|
|
.all();
|
|
|
|
// Re-derive which of the requested fields each row matched, mirroring the
|
|
// LIKE prefix match, so the client can label the hit (e.g. "Ear #", "Color").
|
|
const lowerTerm = term.toLowerCase();
|
|
const labeled = results.map(toSnakeCase).map((row: any) => {
|
|
const valueByField: Record<QuickSearchField, string | null> = {
|
|
farm: row.farm,
|
|
name: row.name,
|
|
ear_number: row.ear_number,
|
|
color: row.color_name,
|
|
};
|
|
const matched_fields = requested.filter((f) =>
|
|
(valueByField[f] ?? '').toLowerCase().startsWith(lowerTerm)
|
|
);
|
|
return { ...row, matched_fields };
|
|
});
|
|
|
|
res.json(labeled);
|
|
});
|
|
|
|
router.get('/:id', (req, res) => {
|
|
const rabbit = fetchRabbitById(Number(req.params.id));
|
|
|
|
if (!rabbit) {
|
|
res.status(404).json({ error: 'Rabbit not found' });
|
|
return;
|
|
}
|
|
|
|
res.json(toSnakeCase(rabbit));
|
|
});
|
|
|
|
router.get('/:id/ancestors', (req, res) => {
|
|
const root = fetchRabbitById(Number(req.params.id));
|
|
if (!root) {
|
|
res.status(404).json({ error: 'Rabbit not found' });
|
|
return;
|
|
}
|
|
|
|
const sire = resolveAncestorNode(root, ['sire']);
|
|
const dam = resolveAncestorNode(root, ['dam']);
|
|
const paternalGrandsire = resolveAncestorNode(root, ['sire', 'sire']);
|
|
const paternalGranddam = resolveAncestorNode(root, ['sire', 'dam']);
|
|
const maternalGrandsire = resolveAncestorNode(root, ['dam', 'sire']);
|
|
const maternalGranddam = resolveAncestorNode(root, ['dam', 'dam']);
|
|
|
|
if (sire) {
|
|
sire.sire = paternalGrandsire;
|
|
sire.dam = paternalGranddam;
|
|
}
|
|
if (dam) {
|
|
dam.sire = maternalGrandsire;
|
|
dam.dam = maternalGranddam;
|
|
}
|
|
if (paternalGrandsire) {
|
|
paternalGrandsire.sire = resolveAncestorNode(root, ['sire', 'sire', 'sire']);
|
|
paternalGrandsire.dam = resolveAncestorNode(root, ['sire', 'sire', 'dam']);
|
|
}
|
|
if (paternalGranddam) {
|
|
paternalGranddam.sire = resolveAncestorNode(root, ['sire', 'dam', 'sire']);
|
|
paternalGranddam.dam = resolveAncestorNode(root, ['sire', 'dam', 'dam']);
|
|
}
|
|
if (maternalGrandsire) {
|
|
maternalGrandsire.sire = resolveAncestorNode(root, ['dam', 'sire', 'sire']);
|
|
maternalGrandsire.dam = resolveAncestorNode(root, ['dam', 'sire', 'dam']);
|
|
}
|
|
if (maternalGranddam) {
|
|
maternalGranddam.sire = resolveAncestorNode(root, ['dam', 'dam', 'sire']);
|
|
maternalGranddam.dam = resolveAncestorNode(root, ['dam', 'dam', 'dam']);
|
|
}
|
|
|
|
res.json({
|
|
id: root.id,
|
|
name: root.name,
|
|
farm: root.farm,
|
|
sex: root.sex,
|
|
ear_number: root.earNumber,
|
|
weight_lbs: root.weightLbs,
|
|
dob: root.dob,
|
|
color_name: root.colorName,
|
|
sire_id: root.sireId,
|
|
dam_id: root.damId,
|
|
sire,
|
|
dam,
|
|
});
|
|
});
|
|
|
|
router.post('/', (req, res) => {
|
|
const { name } = req.body;
|
|
|
|
if (typeof name !== 'string' || name.trim() === '') {
|
|
res.status(400).json({ error: 'Name is required' });
|
|
return;
|
|
}
|
|
|
|
const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as typeof rabbits.$inferInsert;
|
|
|
|
const enumError = invalidEnumError(values);
|
|
if (enumError) {
|
|
res.status(400).json({ error: enumError });
|
|
return;
|
|
}
|
|
|
|
// Adding a rabbit that already exists (same ear number, or same name + farm)
|
|
// tops up that record instead of duplicating it; otherwise ancestors entered
|
|
// as text become linked reference rabbits. See lib/ancestors.ts. One
|
|
// transaction so a failure can't leave half-made ancestor records behind.
|
|
const outcome = db.transaction((tx) => {
|
|
const existing = findExistingSubject(tx, values);
|
|
if (existing) {
|
|
const report = mergeIntoExisting(tx, existing, values);
|
|
const row = tx.select().from(rabbits).where(eq(rabbits.id, existing.id)).get()!;
|
|
return { row, merged: report };
|
|
}
|
|
materializeAncestors(tx, values);
|
|
return { row: tx.insert(rabbits).values(values).returning().get(), merged: null };
|
|
});
|
|
|
|
res
|
|
.status(outcome.merged ? 200 : 201)
|
|
.json({ ...toSnakeCase(outcome.row), merged: outcome.merged && mergeReportJson(outcome.merged) });
|
|
});
|
|
|
|
function mergeReportJson(report: MergeReport) {
|
|
return {
|
|
filled: Object.keys(toSnakeCase(Object.fromEntries(report.filled.map((key) => [key, true])))),
|
|
ancestors_added: report.ancestorsAdded,
|
|
ancestors_updated: report.ancestorsUpdated,
|
|
conflicts: report.conflicts,
|
|
had_photo: report.hadPhoto,
|
|
};
|
|
}
|
|
|
|
// Lets the Add Rabbit review step say up front that saving will top up an
|
|
// existing rabbit. Same matching as POST / uses.
|
|
router.post('/find-existing', (req, res) => {
|
|
const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as typeof rabbits.$inferInsert;
|
|
const existing = findExistingSubject(db, values);
|
|
res.json(
|
|
existing
|
|
? toSnakeCase({ id: existing.id, name: existing.name, farm: existing.farm, earNumber: existing.earNumber, status: existing.status })
|
|
: null,
|
|
);
|
|
});
|
|
|
|
router.patch('/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
const values = pick(toCamelCase(req.body), RABBIT_WRITABLE) as Partial<
|
|
typeof rabbits.$inferInsert
|
|
>;
|
|
|
|
const enumError = invalidEnumError(values);
|
|
if (enumError) {
|
|
res.status(400).json({ error: enumError });
|
|
return;
|
|
}
|
|
|
|
const updated = db
|
|
.update(rabbits)
|
|
.set({ ...values, updatedAt: new Date().toISOString() })
|
|
.where(eq(rabbits.id, id))
|
|
.returning()
|
|
.get();
|
|
|
|
if (!updated) {
|
|
res.status(404).json({ error: 'Rabbit not found' });
|
|
return;
|
|
}
|
|
|
|
res.json(toSnakeCase(updated));
|
|
});
|
|
|
|
router.delete('/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
|
|
const existing = db.select().from(rabbits).where(eq(rabbits.id, id)).get();
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Rabbit not found' });
|
|
return;
|
|
}
|
|
|
|
// Show entries are history the user would lose silently — make them remove
|
|
// those first (the UI suggests Archive instead).
|
|
const showEntryCount = db
|
|
.select({ id: showEventEntries.id })
|
|
.from(showEventEntries)
|
|
.where(eq(showEventEntries.rabbitId, id))
|
|
.all().length;
|
|
if (showEntryCount > 0) {
|
|
res.status(409).json({
|
|
error: `${existing.name} is entered in ${showEntryCount} show${showEntryCount === 1 ? '' : 's'}. Remove those entries first, or archive the rabbit instead.`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Chain-authority: when a rabbit is deleted, its offspring's link to it is
|
|
// broken, so snapshot the deleted rabbit (and the two generations above it
|
|
// that the offspring used to see through it) into the offspring's freeform
|
|
// `_name_text` columns. Without this, deleting a sire would silently erase
|
|
// that whole branch of every descendant's pedigree instead of degrading it to
|
|
// text. All offspring of a given rabbit share the same snapshot, so it's
|
|
// computed once. Wrapped in a transaction with the delete so a partial failure
|
|
// can't leave dangling FKs.
|
|
// Snapshots keep the full manual-ancestor JSON (name, farm, ear #, weight,
|
|
// DOB), not just a name, so the entry can be matched/re-linked later.
|
|
const nameAt = (path: Direction[]) => snapshotText(resolveAncestorNode(existing, path));
|
|
const self = snapshotText(dbAncestorNode(existing));
|
|
|
|
const sireSnapshot = {
|
|
sireId: null,
|
|
sireNameText: self,
|
|
paternalGrandsireNameText: nameAt(['sire']),
|
|
paternalGranddamNameText: nameAt(['dam']),
|
|
ppGrandsireNameText: nameAt(['sire', 'sire']),
|
|
ppGranddamNameText: nameAt(['sire', 'dam']),
|
|
pmGrandsireNameText: nameAt(['dam', 'sire']),
|
|
pmGranddamNameText: nameAt(['dam', 'dam']),
|
|
};
|
|
|
|
const damSnapshot = {
|
|
damId: null,
|
|
damNameText: self,
|
|
maternalGrandsireNameText: nameAt(['sire']),
|
|
maternalGranddamNameText: nameAt(['dam']),
|
|
mpGrandsireNameText: nameAt(['sire', 'sire']),
|
|
mpGranddamNameText: nameAt(['sire', 'dam']),
|
|
mmGrandsireNameText: nameAt(['dam', 'sire']),
|
|
mmGranddamNameText: nameAt(['dam', 'dam']),
|
|
};
|
|
|
|
db.transaction((tx) => {
|
|
tx.update(rabbits).set(sireSnapshot).where(eq(rabbits.sireId, id)).run();
|
|
tx.update(rabbits).set(damSnapshot).where(eq(rabbits.damId, id)).run();
|
|
// SQLite FK enforcement is off, so nothing cascades on its own: litters
|
|
// keep their dates/kit counts with that parent shown as unknown, and the
|
|
// rabbit's own timeline events go with it.
|
|
tx.update(litters).set({ sireId: null }).where(eq(litters.sireId, id)).run();
|
|
tx.update(litters).set({ damId: null }).where(eq(litters.damId, id)).run();
|
|
tx.delete(rabbitEvents).where(eq(rabbitEvents.rabbitId, id)).run();
|
|
tx.delete(rabbits).where(eq(rabbits.id, id)).run();
|
|
});
|
|
|
|
res.status(204).end();
|
|
});
|
|
|
|
router.post('/:id/photo', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
|
|
if (!fetchRabbitById(id)) {
|
|
res.status(404).json({ error: 'Rabbit not found' });
|
|
return;
|
|
}
|
|
|
|
photoUpload(req, res, (err) => {
|
|
if (err) {
|
|
res.status(400).json({ error: 'Invalid file type' });
|
|
return;
|
|
}
|
|
|
|
const file = req.file;
|
|
if (!file) {
|
|
res.status(400).json({ error: 'Invalid file type' });
|
|
return;
|
|
}
|
|
|
|
const photoPath = `/uploads/${path.basename(file.path)}`;
|
|
const updated = db
|
|
.update(rabbits)
|
|
.set({ photoPath, updatedAt: new Date().toISOString() })
|
|
.where(eq(rabbits.id, id))
|
|
.returning()
|
|
.get()!;
|
|
|
|
res.json(toSnakeCase(updated));
|
|
});
|
|
});
|
|
|
|
export default router;
|