const router = require('express').Router(); const { body } = require('express-validator'); const prisma = require('../lib/prisma'); const cache = require('../lib/cache'); const auditMiddleware = require('../middleware/auditMiddleware'); const { validateRequest } = require('../middleware/errorHandler'); const { v4: uuidv4 } = require('uuid'); const { resolveTemplate, DEFAULT_TEMPLATE, UUID_RE } = require('../lib/defaultTemplate'); const experimentValidation = [ body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'), ]; // ── Template validation helpers ──────────────────────────────────────────────── const BUILTIN_FIELD_IDS = new Set(DEFAULT_TEMPLATE.map((f) => f.fieldId)); const BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key)); const VALID_TYPES = new Set(['text', 'textarea']); function validateTemplate(template) { if (!Array.isArray(template)) return 'template must be an array'; if (template.length > 50) return 'template may have at most 50 fields'; const seenFieldIds = new Set(); const seenKeys = new Set(); for (const field of template) { // ── fieldId ──────────────────────────────────────────────────────────────── if (!field.fieldId || !UUID_RE.test(field.fieldId)) return `each field must have a valid UUID fieldId (got: ${field.fieldId})`; if (seenFieldIds.has(field.fieldId)) return `duplicate fieldId "${field.fieldId}"`; seenFieldIds.add(field.fieldId); // Builtin fields must keep their original fieldIds if (BUILTIN_KEYS.has(field.key) && !BUILTIN_FIELD_IDS.has(field.fieldId)) return `builtin field "${field.key}" must keep its original fieldId`; // ── key ──────────────────────────────────────────────────────────────────── if (!field.key || typeof field.key !== 'string') return 'each field must have a string key'; if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`; if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`; if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`; seenKeys.add(field.key); // ── label ────────────────────────────────────────────────────────────────── if (!field.label || typeof field.label !== 'string' || !field.label.trim()) return `field "${field.key}" must have a non-empty label`; if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`; // ── type / active / builtin ──────────────────────────────────────────────── if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`; if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`; if (BUILTIN_KEYS.has(field.key) && field.builtin !== true) return `field "${field.key}" is a builtin field and cannot be made non-builtin`; if (!BUILTIN_KEYS.has(field.key) && field.builtin !== false) return `custom field "${field.key}" must have builtin: false`; // ── abbr / unit / width ──────────────────────────────────────────────────── if (field.abbr !== undefined && field.abbr !== null) { if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`; if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`; } if (field.unit !== undefined && field.unit !== null) { if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`; if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`; } if (field.width !== undefined && field.width !== null) { if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100) return `width for "${field.key}" must be an integer between 1 and 100`; } // ── tags ─────────────────────────────────────────────────────────────────── if (field.tags !== undefined) { if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`; if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`; for (const tag of field.tags) { if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`; if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`; } } if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean') return `tagsDisabled for "${field.key}" must be a boolean`; } return null; } /** * Back-fill any missing fieldIds (handles records saved before this feature). * Builtin fields get their canonical IDs; custom fields get fresh UUIDs. */ function ensureFieldIds(template) { const builtinById = Object.fromEntries(DEFAULT_TEMPLATE.map((f) => [f.key, f.fieldId])); return template.map((f) => ({ ...f, fieldId: f.fieldId || (f.builtin ? builtinById[f.key] : uuidv4()), tags: f.tags ?? [], // always return tags array (empty if not yet set) })); } // ── Experiment CRUD ──────────────────────────────────────────────────────────── // GET /api/experiments router.get('/', async (req, res, next) => { try { const experiments = await prisma.experiment.findMany({ orderBy: { created_at: 'desc' }, include: { _count: { select: { animals: true } } }, }); res.json(experiments); } catch (err) { next(err); } }); // GET /api/experiments/:id router.get('/:id', async (req, res, next) => { try { const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id }, include: { animals: true }, }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); experiment.template = ensureFieldIds(resolveTemplate(experiment.template)); res.json(experiment); } catch (err) { next(err); } }); // POST /api/experiments router.post('/', experimentValidation, validateRequest, async (req, res, next) => { try { const { title } = req.body; const experiment = await prisma.experiment.create({ data: { title } }); await prisma.auditLog.create({ data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } }, }); experiment.template = ensureFieldIds(resolveTemplate(experiment.template)); res.status(201).json(experiment); } catch (err) { next(err); } }); // PUT /api/experiments/:id router.put( '/:id', auditMiddleware('experiments', prisma.experiment), experimentValidation, validateRequest, async (req, res, next) => { try { const { title } = req.body; const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } }); experiment.template = ensureFieldIds(resolveTemplate(experiment.template)); res.json(experiment); } catch (err) { next(err); } } ); // DELETE /api/experiments/:id router.delete( '/:id', auditMiddleware('experiments', prisma.experiment), async (req, res, next) => { try { await prisma.experiment.delete({ where: { id: req.params.id } }); res.status(204).send(); } catch (err) { next(err); } } ); // ── Subject-template validation ──────────────────────────────────────────────── function validateSubjectTemplate(template) { if (!Array.isArray(template)) return 'subject_info_template must be an array'; if (template.length > 50) return 'subject_info_template may have at most 50 fields'; const seenFieldIds = new Set(); const seenKeys = new Set(); for (const field of template) { if (!field.fieldId || !UUID_RE.test(field.fieldId)) return `each field must have a valid UUID fieldId (got: ${field.fieldId})`; if (seenFieldIds.has(field.fieldId)) return `duplicate fieldId "${field.fieldId}"`; seenFieldIds.add(field.fieldId); if (!field.key || typeof field.key !== 'string') return 'each field must have a string key'; if (!/^[a-z0-9_]+$/.test(field.key)) return `key "${field.key}" must be lowercase alphanumeric/underscore only`; if (field.key.length > 64) return `key "${field.key}" must be ≤64 characters`; if (seenKeys.has(field.key)) return `duplicate key "${field.key}"`; seenKeys.add(field.key); if (!field.label || typeof field.label !== 'string' || !field.label.trim()) return `field "${field.key}" must have a non-empty label`; if (field.label.length > 128) return `label for "${field.key}" must be ≤128 characters`; if (!VALID_TYPES.has(field.type)) return `field "${field.key}" type must be "text" or "textarea"`; if (typeof field.active !== 'boolean') return `field "${field.key}" must have a boolean active flag`; if (field.builtin !== false) return `subject field "${field.key}" must have builtin: false`; if (field.abbr !== undefined && field.abbr !== null) { if (typeof field.abbr !== 'string') return `abbr for "${field.key}" must be a string`; if (field.abbr.length > 10) return `abbr for "${field.key}" must be ≤10 characters`; } if (field.unit !== undefined && field.unit !== null) { if (typeof field.unit !== 'string') return `unit for "${field.key}" must be a string`; if (field.unit.length > 10) return `unit for "${field.key}" must be ≤10 characters`; } if (field.width !== undefined && field.width !== null) { if (!Number.isInteger(field.width) || field.width < 1 || field.width > 100) return `width for "${field.key}" must be an integer between 1 and 100`; } if (field.tags !== undefined) { if (!Array.isArray(field.tags)) return `tags for "${field.key}" must be an array`; if (field.tags.length > 50) return `field "${field.key}" may have at most 50 tags`; for (const tag of field.tags) { if (typeof tag !== 'string' || !tag.trim()) return `tags for "${field.key}" must be non-empty strings`; if (tag.length > 512) return `a tag in "${field.key}" exceeds 512 characters`; } } if (field.tagsDisabled !== undefined && typeof field.tagsDisabled !== 'boolean') return `tagsDisabled for "${field.key}" must be a boolean`; } return null; } // Upper bound on the stored UI-preference blob. Sized to comfortably hold the known // fields plus a full hiddenSubjects list (≤500 UUIDs ≈ 20 KB) with headroom, while // still guarding against a client persisting an arbitrarily large object. const PLOT_CONFIG_MAX_BYTES = 64 * 1024; // The blob is intentionally schema-loose (clients may add display keys over time); // only hiddenSubjects is structurally validated since the backend reasons about it. function validatePlotConfig(config) { if (config === null || typeof config !== 'object' || Array.isArray(config)) { return 'plot_config must be an object'; } // req.body is already JSON-parsed, so stringify cannot throw here. if (Buffer.byteLength(JSON.stringify(config), 'utf8') > PLOT_CONFIG_MAX_BYTES) { return 'plot_config too large'; } if ('hiddenSubjects' in config) { const hidden = config.hiddenSubjects; if (!Array.isArray(hidden) || hidden.length > 500 || !hidden.every((x) => typeof x === 'string')) { return 'hiddenSubjects invalid'; } } return null; } // ── Template endpoints ───────────────────────────────────────────────────────── // GET /api/experiments/:id/template router.get('/:id/template', async (req, res, next) => { try { const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id }, select: { id: true, template: true }, }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); res.json(ensureFieldIds(resolveTemplate(experiment.template))); } catch (err) { next(err); } }); // PUT /api/experiments/:id/template router.put('/:id/template', async (req, res, next) => { try { const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); const template = req.body; const validationError = validateTemplate(template); if (validationError) return res.status(422).json({ error: validationError }); const before = ensureFieldIds(resolveTemplate(experiment.template)); const updated = await prisma.experiment.update({ where: { id: req.params.id }, data: { template }, }); await prisma.auditLog.create({ data: { table_name: 'experiments', record_id: experiment.id, action: 'UPDATE', changes: { before: { template: before }, after: { template } }, }, }); res.json(template); } catch (err) { next(err); } }); // GET /api/experiments/:id/subject-template router.get('/:id/subject-template', async (req, res, next) => { try { const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id }, select: { id: true, subject_info_template: true }, }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); const tmpl = Array.isArray(experiment.subject_info_template) ? experiment.subject_info_template : []; res.json(tmpl.map((f) => ({ ...f, tags: f.tags ?? [] }))); } catch (err) { next(err); } }); // PUT /api/experiments/:id/subject-template router.put('/:id/subject-template', async (req, res, next) => { try { const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); const template = req.body; const validationError = validateSubjectTemplate(template); if (validationError) return res.status(422).json({ error: validationError }); const updated = await prisma.experiment.update({ where: { id: req.params.id }, data: { subject_info_template: template }, }); await prisma.auditLog.create({ data: { table_name: 'experiments', record_id: experiment.id, action: 'UPDATE', changes: { before: { subject_info_template: experiment.subject_info_template }, after: { subject_info_template: template } }, }, }); res.json(template); } catch (err) { next(err); } }); // PUT /api/experiments/:id/plot-config — persist UI display settings (no audit log) router.put('/:id/plot-config', async (req, res, next) => { try { const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); const config = req.body; const validationError = validatePlotConfig(config); if (validationError) return res.status(422).json({ error: validationError }); await prisma.experiment.update({ where: { id: req.params.id }, data: { plot_config: config }, }); res.json(config); } catch (err) { next(err); } }); // GET /api/experiments/:id/calendar?field= // Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day. router.get('/:id/calendar', async (req, res, next) => { try { const field = req.query.field ?? ''; const cacheKey = `exp:${req.params.id}:cal:${field}`; const cached = cache.get(cacheKey); if (cached) return res.json(cached); const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } }); if (!exp) return res.status(404).json({ error: 'Experiment not found' }); const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); const isBuiltin = BUILTIN_KEYS_SET.has(field); const statuses = await prisma.dailyStatus.findMany({ where: { animal: { experiment_id: req.params.id } }, select: { date: true, experiment_description: true, vitals: true, treatment: true, notes: true, custom_fields: true, }, }); const days = {}; for (const s of statuses) { const value = isBuiltin ? s[field] : s.custom_fields?.[field]; if (value !== null && value !== undefined && String(value).trim() !== '') { const dateKey = new Date(s.date).toISOString().slice(0, 10); days[dateKey] = (days[dateKey] || 0) + 1; } } const result = { field, days }; cache.set(cacheKey, result); res.json(result); } catch (err) { next(err); } }); // GET /api/experiments/:id/daily-statuses — all statuses for all subjects, analysis_summary included // ?date=YYYY-MM-DD — filter to a single day // ?analysisOnly=true — only return statuses that have a saved analysis_summary (for charts) router.get('/:id/daily-statuses', async (req, res, next) => { try { const date = req.query.date ?? ''; const analysisOnly = req.query.analysisOnly === 'true'; const cacheKey = `exp:${req.params.id}:ds:${analysisOnly ? '1' : '0'}:${date}`; const cached = cache.get(cacheKey); if (cached) return res.json(cached); const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } }); if (!exp) return res.status(404).json({ error: 'Experiment not found' }); const where = { animal: { experiment_id: req.params.id } }; if (date) { const d = new Date(date + 'T00:00:00.000Z'); const next = new Date(d); next.setUTCDate(next.getUTCDate() + 1); where.date = { gte: d, lt: next }; } let statuses = await prisma.dailyStatus.findMany({ where, select: { id: true, animal_id: true, date: true, analysis_summary: true, experiment_description: true, vitals: true, treatment: true, notes: true, custom_fields: true, }, orderBy: { date: 'asc' }, }); // Prisma 5 has no clean IS NOT NULL filter for Json? fields; filter in JS. // The result is cached so this only runs once per cache period. if (analysisOnly) { statuses = statuses.filter((s) => s.analysis_summary !== null); } cache.set(cacheKey, statuses); res.json(statuses); } catch (err) { next(err); } }); module.exports = router;