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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
|
||||
@@ -14,6 +14,7 @@ model Experiment {
|
||||
created_at DateTime @default(now())
|
||||
template Json @default("[]")
|
||||
subject_info_template Json @default("[]")
|
||||
plot_config Json?
|
||||
animals Animal[]
|
||||
|
||||
@@map("experiments")
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -136,3 +136,49 @@ describe('GET /health', () => {
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/experiments/:id/plot-config', () => {
|
||||
const CONFIG = { xField: '__days__', tickStep: 2, hiddenSubjects: ['a1', 'a2'], sidebarOpen: true };
|
||||
|
||||
it('saves a valid config and returns it without writing an audit log', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
prisma.experiment.update.mockResolvedValue({ ...EXPERIMENT, plot_config: CONFIG });
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(CONFIG);
|
||||
expect(prisma.experiment.update).toHaveBeenCalledWith({
|
||||
where: { id: EXPERIMENT.id },
|
||||
data: { plot_config: CONFIG },
|
||||
});
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the experiment does not exist', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(null);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-object body with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send([1, 2, 3]);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid hiddenSubjects with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send({ hiddenSubjects: [1, 2] });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an oversized config with 422', async () => {
|
||||
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||
const big = { blob: 'x'.repeat(20000) };
|
||||
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(big);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.experiment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user