feat(backend): persist per-experiment plot_config via PUT /:id/plot-config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-06-01 10:12:23 -04:00
parent 2dd4a329ee
commit e9511bdac3
4 changed files with 90 additions and 0 deletions
+41
View File
@@ -224,6 +224,29 @@ function validateSubjectTemplate(template) {
return null;
}
// Upper bound on the stored UI-preference blob. Generous for the known fields;
// guards against a client persisting an arbitrarily large object.
const PLOT_CONFIG_MAX_BYTES = 16 * 1024;
// The blob is intentionally schema-loose (clients may add display keys over time);
// only hiddenSubjects is structurally validated since the backend reasons about it.
function validatePlotConfig(config) {
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
return 'plot_config must be an object';
}
// req.body is already JSON-parsed, so stringify cannot throw here.
if (Buffer.byteLength(JSON.stringify(config), 'utf8') > PLOT_CONFIG_MAX_BYTES) {
return 'plot_config too large';
}
if ('hiddenSubjects' in config) {
const hidden = config.hiddenSubjects;
if (!Array.isArray(hidden) || hidden.length > 500 || !hidden.every((x) => typeof x === 'string')) {
return 'hiddenSubjects invalid';
}
}
return null;
}
// ── Template endpoints ─────────────────────────────────────────────────────────
// GET /api/experiments/:id/template
@@ -308,6 +331,24 @@ router.put('/:id/subject-template', async (req, res, next) => {
} catch (err) { next(err); }
});
// PUT /api/experiments/:id/plot-config — persist UI display settings (no audit log)
router.put('/:id/plot-config', 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 config = req.body;
const validationError = validatePlotConfig(config);
if (validationError) return res.status(422).json({ error: validationError });
await prisma.experiment.update({
where: { id: req.params.id },
data: { plot_config: config },
});
res.json(config);
} catch (err) { next(err); }
});
// GET /api/experiments/:id/calendar?field=<fieldId|builtinKey>
// Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day.
router.get('/:id/calendar', async (req, res, next) => {