b0c6217cb4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
446 lines
19 KiB
JavaScript
446 lines
19 KiB
JavaScript
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=<fieldId|builtinKey>
|
|
// 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;
|