feat(template): per-experiment daily record template — rename/add/hide fields, custom_fields JSONB
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user