feat(template): stable fieldId per field — custom_fields keyed by UUID, not label slug

This commit is contained in:
Experiments DB Dev
2026-04-15 14:01:14 -04:00
parent 1305e53f96
commit b7374777d3
5 changed files with 84 additions and 38 deletions
+18 -14
View File
@@ -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 };
+41 -14
View File
@@ -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 },