87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
import { Router } from 'express';
|
||
import { sql } from 'drizzle-orm';
|
||
import multer from 'multer';
|
||
import { db } from '../db/client';
|
||
import { rabbits } from '../db/schema';
|
||
import { isPedigreeScanEnabled, PedigreeScanError, scanPedigreeImage } from '../lib/pedigreeScan';
|
||
|
||
const router = Router();
|
||
|
||
const MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'] as const);
|
||
type MediaType = 'image/jpeg' | 'image/png' | 'image/webp';
|
||
|
||
// In memory: the image is forwarded to the API and discarded, never written to
|
||
// disk. The client downsizes to ~2400px JPEG first, so real uploads are well
|
||
// under the limit.
|
||
const upload = multer({
|
||
storage: multer.memoryStorage(),
|
||
limits: { fileSize: 10 * 1024 * 1024 },
|
||
}).single('image');
|
||
|
||
// Lets the client decide whether to show the "Scan a pedigree" button.
|
||
router.get('/', (req, res) => {
|
||
res.json({ enabled: isPedigreeScanEnabled() });
|
||
});
|
||
|
||
// Confident herd match for a scanned sire/dam (FEATURE_OCR_Pedigree_Scan.md
|
||
// Q6): an exact ear number wins; otherwise exactly one exact-name match.
|
||
// Anything looser stays a manual entry the user can link by hand.
|
||
function findHerdMatch(name: string | null, earNumber: string | null) {
|
||
const columns = { id: rabbits.id, name: rabbits.name };
|
||
if (earNumber) {
|
||
const byEar = db
|
||
.select(columns)
|
||
.from(rabbits)
|
||
.where(sql`upper(${rabbits.earNumber}) = ${earNumber.toUpperCase()}`)
|
||
.limit(2)
|
||
.all();
|
||
if (byEar.length === 1) return byEar[0];
|
||
}
|
||
if (name) {
|
||
const byName = db
|
||
.select(columns)
|
||
.from(rabbits)
|
||
.where(sql`lower(${rabbits.name}) = ${name.toLowerCase()}`)
|
||
.limit(2)
|
||
.all();
|
||
if (byName.length === 1) return byName[0];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
router.post('/', (req, res, next) => {
|
||
if (!isPedigreeScanEnabled()) {
|
||
res.status(503).json({ error: 'Pedigree scanning isn’t set up on this server.' });
|
||
return;
|
||
}
|
||
|
||
upload(req, res, async (uploadErr) => {
|
||
if (uploadErr) {
|
||
res.status(400).json({ error: uploadErr.message });
|
||
return;
|
||
}
|
||
const file = req.file;
|
||
if (!file || !MEDIA_TYPES.has(file.mimetype as MediaType)) {
|
||
res.status(400).json({ error: 'Upload a JPEG, PNG, or WebP photo of the pedigree.' });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await scanPedigreeImage(file.buffer, file.mimetype as MediaType);
|
||
const matches = {
|
||
sire: findHerdMatch(result.ancestors.sire.name, result.ancestors.sire.ear_number),
|
||
dam: findHerdMatch(result.ancestors.dam.name, result.ancestors.dam.ear_number),
|
||
};
|
||
res.json({ ...result, matches });
|
||
} catch (err) {
|
||
if (err instanceof PedigreeScanError) {
|
||
res.status(err.status).json({ error: err.message });
|
||
return;
|
||
}
|
||
next(err);
|
||
}
|
||
});
|
||
});
|
||
|
||
export default router;
|