From 86a56427b77615467fa606e8e04dc890a79d44ff Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Wed, 15 Apr 2026 13:56:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(template):=20per-experiment=20daily=20reco?= =?UTF-8?q?rd=20template=20=E2=80=94=20rename/add/hide=20fields,=20custom?= =?UTF-8?q?=5Ffields=20JSONB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 5 + backend/prisma/schema.prisma | 2 + backend/src/lib/defaultTemplate.js | 25 ++ backend/src/routes/dailyStatuses.js | 62 ++--- backend/src/routes/experiments.js | 129 +++++++--- frontend/src/api/client.js | 2 + frontend/src/components/DailyStatusForm.jsx | 166 +++++++------ frontend/src/components/TemplateEditor.jsx | 225 ++++++++++++++++++ frontend/src/pages/AnimalDetail.jsx | 80 +++++-- frontend/src/pages/ExperimentDetail.jsx | 63 ++++- 10 files changed, 570 insertions(+), 189 deletions(-) create mode 100644 backend/prisma/migrations/20240102000000_add_template_and_custom_fields/migration.sql create mode 100644 backend/src/lib/defaultTemplate.js create mode 100644 frontend/src/components/TemplateEditor.jsx diff --git a/backend/prisma/migrations/20240102000000_add_template_and_custom_fields/migration.sql b/backend/prisma/migrations/20240102000000_add_template_and_custom_fields/migration.sql new file mode 100644 index 0000000..6d331d5 --- /dev/null +++ b/backend/prisma/migrations/20240102000000_add_template_and_custom_fields/migration.sql @@ -0,0 +1,5 @@ +-- Add template field to experiments (JSON array of field definitions) +ALTER TABLE "experiments" ADD COLUMN "template" JSONB NOT NULL DEFAULT '[]'; + +-- Add custom_fields to daily_statuses (stores values for non-builtin fields) +ALTER TABLE "daily_statuses" ADD COLUMN "custom_fields" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6b4d19d..5c861dc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,6 +12,7 @@ model Experiment { id String @id @default(uuid()) title String created_at DateTime @default(now()) + template Json @default("[]") animals Animal[] @@map("experiments") @@ -36,6 +37,7 @@ model DailyStatus { vitals String? treatment String? notes String? + custom_fields Json? animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) @@map("daily_statuses") diff --git a/backend/src/lib/defaultTemplate.js b/backend/src/lib/defaultTemplate.js new file mode 100644 index 0000000..d82e5d4 --- /dev/null +++ b/backend/src/lib/defaultTemplate.js @@ -0,0 +1,25 @@ +/** + * The default daily-record template applied to every new experiment + * (and returned as a fallback when an experiment's template is empty). + * + * `builtin: true` — value lives in the named column of daily_statuses + * `builtin: false` — value lives in daily_statuses.custom_fields JSON + * `active: false` — field is hidden (not deleted; data preserved) + */ +const DEFAULT_TEMPLATE = [ + { key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true }, + { key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true }, + { key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true }, + { key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true }, +]; + +/** + * Resolve the effective template for an experiment. + * If the stored template is empty (new experiment), return the default. + */ +function resolveTemplate(stored) { + if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE; + return stored; +} + +module.exports = { DEFAULT_TEMPLATE, resolveTemplate }; diff --git a/backend/src/routes/dailyStatuses.js b/backend/src/routes/dailyStatuses.js index c32adef..efb09eb 100644 --- a/backend/src/routes/dailyStatuses.js +++ b/backend/src/routes/dailyStatuses.js @@ -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= — list for animal +// GET /api/daily-statuses?animalId= 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); } } ); diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index a6b7c74..5343455 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -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; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 52163d2..4845d8c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -29,6 +29,8 @@ export const experimentsApi = { create: (data) => api.post('/experiments', data).then((r) => r.data), update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data), delete: (id) => api.delete(`/experiments/${id}`), + getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data), + updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data), }; // ── Animals ─────────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/DailyStatusForm.jsx b/frontend/src/components/DailyStatusForm.jsx index 8e3641e..b5e8942 100644 --- a/frontend/src/components/DailyStatusForm.jsx +++ b/frontend/src/components/DailyStatusForm.jsx @@ -5,42 +5,48 @@ import FormField, { Input, Textarea } from './ui/FormField'; import Alert from './ui/Alert'; import { dailyStatusesApi } from '../api/client'; -export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) { +const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); + +/** Extract the value for a field from a status record */ +function getValue(status, field) { + if (!status) return ''; + if (field.builtin) return status[field.key] ?? ''; + return status.custom_fields?.[field.key] ?? ''; +} + +export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) { const today = format(new Date(), 'yyyy-MM-dd'); - const [fields, setFields] = useState({ - date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today, - experiment_description: existing?.experiment_description ?? '', - vitals: existing?.vitals ?? '', - treatment: existing?.treatment ?? '', - notes: existing?.notes ?? '', - }); + + // Active fields from template (date is always first and handled separately) + const activeFields = template.filter((f) => f.active); + + function buildInitialValues() { + const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today }; + for (const f of activeFields) { + vals[f.key] = getValue(existing, f); + } + return vals; + } + + const [values, setValues] = useState(buildInitialValues); const [errors, setErrors] = useState({}); const [apiError, setApiError] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { - setFields({ - date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today, - experiment_description: existing?.experiment_description ?? '', - vitals: existing?.vitals ?? '', - treatment: existing?.treatment ?? '', - notes: existing?.notes ?? '', - }); + setValues(buildInitialValues()); setErrors({}); setApiError(null); - }, [existing]); + }, [existing, template]); - const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value })); + const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value })); function validate() { const e = {}; - if (!fields.date) { + if (!values.date) { e.date = 'Date is required'; - } else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) { - e.date = 'Date must be in YYYY-MM-DD format'; - } else { - const d = new Date(fields.date); - if (isNaN(d.getTime())) e.date = 'Invalid date'; + } else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) || isNaN(new Date(values.date).getTime())) { + e.date = 'Date must be a valid YYYY-MM-DD date'; } setErrors(e); return Object.keys(e).length === 0; @@ -52,16 +58,26 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance setLoading(true); setApiError(null); try { - const payload = { - date: fields.date, - experiment_description: fields.experiment_description || null, - vitals: fields.vitals || null, - treatment: fields.treatment || null, - notes: fields.notes || null, - }; + // Separate builtin values from custom_fields + const builtin = {}; + const custom = {}; + for (const f of activeFields) { + const val = values[f.key] || null; + if (f.builtin) builtin[f.key] = val; + else custom[f.key] = val; + } + + // Merge with any pre-existing custom_fields not in current active template + // (so we don't wipe data for fields that were hidden after the entry was made) + const existingCustom = existing?.custom_fields ?? {}; + const mergedCustom = { ...existingCustom, ...custom }; + + const payload = { date: values.date, ...builtin, custom_fields: mergedCustom }; + const result = existing ? await dailyStatusesApi.update(existing.id, payload) : await dailyStatusesApi.create({ ...payload, animal_id: animalId }); + onSuccess(result); } catch (err) { setApiError(err); @@ -73,66 +89,44 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance return (
{apiError && ( - setApiError(null)} - /> + setApiError(null)} /> )} + + {/* Date — always present */} - - - -