From b7374777d35377065d20f84568b3d37841986370 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Wed, 15 Apr 2026 14:01:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(template):=20stable=20fieldId=20per=20fiel?= =?UTF-8?q?d=20=E2=80=94=20custom=5Ffields=20keyed=20by=20UUID,=20not=20la?= =?UTF-8?q?bel=20slug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/lib/defaultTemplate.js | 32 ++++++------ backend/src/routes/experiments.js | 55 +++++++++++++++------ frontend/src/components/DailyStatusForm.jsx | 18 ++++--- frontend/src/components/TemplateEditor.jsx | 12 ++++- frontend/src/pages/AnimalDetail.jsx | 5 +- 5 files changed, 84 insertions(+), 38 deletions(-) diff --git a/backend/src/lib/defaultTemplate.js b/backend/src/lib/defaultTemplate.js index d82e5d4..7b078f8 100644 --- a/backend/src/lib/defaultTemplate.js +++ b/backend/src/lib/defaultTemplate.js @@ -1,25 +1,29 @@ /** - * The default daily-record template applied to every new experiment - * (and returned as a fallback when an experiment's template is empty). + * The default daily-record template applied to every new experiment. * - * `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) + * Builtin fields have FIXED fieldIds so they are stable across all experiments + * and can never be confused with user-created fields, even if a user creates a + * custom field with the same label. + * + * `fieldId` — stable UUID used as the storage key; never changes even if + * the field is renamed, reordered, or temporarily removed + * `key` — human-readable slug (display only, not used for storage) + * `builtin` — true: value stored in the dedicated column of daily_statuses + * false: value stored in daily_statuses.custom_fields[fieldId] + * `active` — false: field is hidden (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 }, + { fieldId: '00000000-0000-0000-0000-000000000001', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true }, + { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true }, + { fieldId: '00000000-0000-0000-0000-000000000003', key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true }, + { fieldId: '00000000-0000-0000-0000-000000000004', 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. - */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + function resolveTemplate(stored) { if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_TEMPLATE; return stored; } -module.exports = { DEFAULT_TEMPLATE, resolveTemplate }; +module.exports = { DEFAULT_TEMPLATE, resolveTemplate, UUID_RE }; diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 5343455..9b4ceca 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -3,7 +3,8 @@ 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 { 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'), @@ -11,30 +12,45 @@ const experimentValidation = [ // ── Template validation helpers ──────────────────────────────────────────────── -const BUILTIN_KEYS = new Set(DEFAULT_TEMPLATE.map((f) => f.key)); -const VALID_TYPES = new Set(['text', 'textarea']); +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 keys = new Set(); + 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 (keys.has(field.key)) return `duplicate key "${field.key}"`; - keys.add(field.key); + 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`; - // 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) @@ -43,6 +59,18 @@ function validateTemplate(template) { 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()), + })); +} + // ── Experiment CRUD ──────────────────────────────────────────────────────────── // GET /api/experiments @@ -64,8 +92,7 @@ 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); + experiment.template = ensureFieldIds(resolveTemplate(experiment.template)); res.json(experiment); } catch (err) { next(err); } }); @@ -78,7 +105,7 @@ router.post('/', experimentValidation, validateRequest, async (req, res, next) = await prisma.auditLog.create({ data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } }, }); - experiment.template = resolveTemplate(experiment.template); + experiment.template = ensureFieldIds(resolveTemplate(experiment.template)); res.status(201).json(experiment); } catch (err) { next(err); } }); @@ -93,7 +120,7 @@ router.put( try { const { title } = req.body; const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } }); - experiment.template = resolveTemplate(experiment.template); + experiment.template = ensureFieldIds(resolveTemplate(experiment.template)); res.json(experiment); } catch (err) { next(err); } } @@ -121,7 +148,7 @@ router.get('/:id/template', async (req, res, next) => { select: { id: true, template: true }, }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); - res.json(resolveTemplate(experiment.template)); + res.json(ensureFieldIds(resolveTemplate(experiment.template))); } catch (err) { next(err); } }); @@ -135,7 +162,7 @@ router.put('/:id/template', async (req, res, next) => { const validationError = validateTemplate(template); if (validationError) return res.status(422).json({ error: validationError }); - const before = resolveTemplate(experiment.template); + const before = ensureFieldIds(resolveTemplate(experiment.template)); const updated = await prisma.experiment.update({ where: { id: req.params.id }, data: { template }, diff --git a/frontend/src/components/DailyStatusForm.jsx b/frontend/src/components/DailyStatusForm.jsx index b5e8942..4f08887 100644 --- a/frontend/src/components/DailyStatusForm.jsx +++ b/frontend/src/components/DailyStatusForm.jsx @@ -7,11 +7,12 @@ import { dailyStatusesApi } from '../api/client'; const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); -/** Extract the value for a field from a status record */ +/** Extract the value for a field from a status record. + * Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */ function getValue(status, field) { if (!status) return ''; if (field.builtin) return status[field.key] ?? ''; - return status.custom_fields?.[field.key] ?? ''; + return status.custom_fields?.[field.fieldId] ?? ''; } export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) { @@ -63,12 +64,17 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS 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; + if (f.builtin) { + builtin[f.key] = val; + } else { + // Always key by fieldId so renaming or recreating a field never + // collides with or orphans data from a different field. + custom[f.fieldId] = 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) + // Preserve custom_fields for fields not in the current active template + // (e.g. a field was hidden after this record was created). const existingCustom = existing?.custom_fields ?? {}; const mergedCustom = { ...existingCustom, ...custom }; diff --git a/frontend/src/components/TemplateEditor.jsx b/frontend/src/components/TemplateEditor.jsx index f30edd4..55cac5f 100644 --- a/frontend/src/components/TemplateEditor.jsx +++ b/frontend/src/components/TemplateEditor.jsx @@ -53,7 +53,14 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) { onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }} /> {labelError &&

{labelError}

} -

key: {field.key}

+

+ key: {field.key} + {field.fieldId && ( + + · id: {field.fieldId.slice(0, 8)}… + + )} +

{/* Type badge */} @@ -133,7 +140,8 @@ export default function TemplateEditor({ experimentId, onClose }) { if (!key) { setAddError('Name must contain at least one alphanumeric character'); return; } if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; } setAddError(''); - setFields((prev) => [...prev, { key, label: trimmed, type: newType, builtin: false, active: true }]); + const fieldId = crypto.randomUUID(); + setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true }]); setNewLabel(''); setNewType('text'); } diff --git a/frontend/src/pages/AnimalDetail.jsx b/frontend/src/pages/AnimalDetail.jsx index 678cf3a..3c36005 100644 --- a/frontend/src/pages/AnimalDetail.jsx +++ b/frontend/src/pages/AnimalDetail.jsx @@ -9,10 +9,11 @@ import DailyStatusForm from '../components/DailyStatusForm'; import Alert from '../components/ui/Alert'; import AuditLogSection from '../components/AuditLogSection'; -/** Render the value for a template field from a status record */ +/** Read the value for a template field from a status record. + * Custom fields are keyed by fieldId, not by the display key. */ function getFieldValue(status, field) { if (field.builtin) return status[field.key]; - return status.custom_fields?.[field.key]; + return status.custom_fields?.[field.fieldId]; } export default function AnimalDetail() {