feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB

This commit is contained in:
Experiments DB Dev
2026-04-15 13:56:17 -04:00
parent 80fb1c6e27
commit 86a56427b7
10 changed files with 570 additions and 189 deletions
+23 -39
View File
@@ -11,6 +11,7 @@ const statusValidation = [
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
];
const statusUpdateValidation = [
@@ -19,9 +20,10 @@ const statusUpdateValidation = [
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
];
// GET /api/daily-statuses?animalId=<uuid> — list for animal
// GET /api/daily-statuses?animalId=<uuid>
router.get(
'/',
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
@@ -35,13 +37,11 @@ router.get(
include: { animal: { select: { animal_name: true, animal_id_string: true } } },
});
res.json(statuses);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// GET /api/daily-statuses/:id — get one
// GET /api/daily-statuses/:id
router.get('/:id', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({
@@ -50,15 +50,13 @@ router.get('/:id', async (req, res, next) => {
});
if (!status) return res.status(404).json({ error: 'Daily status not found' });
res.json(status);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// POST /api/daily-statuses — create
// POST /api/daily-statuses
router.post('/', statusValidation, validateRequest, async (req, res, next) => {
try {
const { animal_id, date, experiment_description, vitals, treatment, notes } = req.body;
const { animal_id, date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
const status = await prisma.dailyStatus.create({
data: {
animal_id,
@@ -67,25 +65,17 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => {
vitals,
treatment,
notes,
custom_fields: custom_fields ?? {},
},
});
await prisma.auditLog.create({
data: {
table_name: 'daily_statuses',
record_id: status.id,
action: 'CREATE',
changes: { before: null, after: status },
},
data: { table_name: 'daily_statuses', record_id: status.id, action: 'CREATE', changes: { before: null, after: status } },
});
res.status(201).json(status);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// PUT /api/daily-statuses/:id — update
// PUT /api/daily-statuses/:id
router.put(
'/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus),
@@ -93,26 +83,22 @@ router.put(
validateRequest,
async (req, res, next) => {
try {
const { date, experiment_description, vitals, treatment, notes } = req.body;
const { date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
const data = {};
if (date !== undefined) data.date = new Date(date);
if (experiment_description !== undefined) data.experiment_description = experiment_description;
if (vitals !== undefined) data.vitals = vitals;
if (treatment !== undefined) data.treatment = treatment;
if (notes !== undefined) data.notes = notes;
if (date !== undefined) data.date = new Date(date);
if (experiment_description !== undefined) data.experiment_description = experiment_description;
if (vitals !== undefined) data.vitals = vitals;
if (treatment !== undefined) data.treatment = treatment;
if (notes !== undefined) data.notes = notes;
if (custom_fields !== undefined) data.custom_fields = custom_fields;
const status = await prisma.dailyStatus.update({
where: { id: req.params.id },
data,
});
const status = await prisma.dailyStatus.update({ where: { id: req.params.id }, data });
res.json(status);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// DELETE /api/daily-statuses/:id — delete
// DELETE /api/daily-statuses/:id
router.delete(
'/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus),
@@ -120,9 +106,7 @@ router.delete(
try {
await prisma.dailyStatus.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
+96 -33
View File
@@ -3,12 +3,49 @@ const { body } = require('express-validator');
const prisma = require('../lib/prisma');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const { resolveTemplate, DEFAULT_TEMPLATE } = require('../lib/defaultTemplate');
const experimentValidation = [
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
];
// GET /api/experiments — list all
// ── Template validation helpers ────────────────────────────────────────────────
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 keys = new Set();
for (const field of template) {
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 (keys.has(field.key)) return `duplicate key "${field.key}"`;
keys.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`;
// Builtin keys cannot change their builtin status
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`;
}
return null;
}
// ── Experiment CRUD ────────────────────────────────────────────────────────────
// GET /api/experiments
router.get('/', async (req, res, next) => {
try {
const experiments = await prisma.experiment.findMany({
@@ -16,12 +53,10 @@ router.get('/', async (req, res, next) => {
include: { _count: { select: { animals: true } } },
});
res.json(experiments);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// GET /api/experiments/:id — get one with animals
// GET /api/experiments/:id
router.get('/:id', async (req, res, next) => {
try {
const experiment = await prisma.experiment.findUnique({
@@ -29,35 +64,26 @@ router.get('/:id', async (req, res, next) => {
include: { animals: true },
});
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
// Always return resolved template (never raw [])
experiment.template = resolveTemplate(experiment.template);
res.json(experiment);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// POST /api/experiments — create
// POST /api/experiments
router.post('/', experimentValidation, validateRequest, async (req, res, next) => {
try {
const { title } = req.body;
const experiment = await prisma.experiment.create({ data: { title } });
// Write CREATE audit log
await prisma.auditLog.create({
data: {
table_name: 'experiments',
record_id: experiment.id,
action: 'CREATE',
changes: { before: null, after: experiment },
},
data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } },
});
experiment.template = resolveTemplate(experiment.template);
res.status(201).json(experiment);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
});
// PUT /api/experiments/:id — update
// PUT /api/experiments/:id
router.put(
'/:id',
auditMiddleware('experiments', prisma.experiment),
@@ -66,18 +92,14 @@ router.put(
async (req, res, next) => {
try {
const { title } = req.body;
const experiment = await prisma.experiment.update({
where: { id: req.params.id },
data: { title },
});
const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } });
experiment.template = resolveTemplate(experiment.template);
res.json(experiment);
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// DELETE /api/experiments/:id — delete
// DELETE /api/experiments/:id
router.delete(
'/:id',
auditMiddleware('experiments', prisma.experiment),
@@ -85,10 +107,51 @@ router.delete(
try {
await prisma.experiment.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
} catch (err) { next(err); }
}
);
// ── 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(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 = 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); }
});
module.exports = router;