diff --git a/backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql b/backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql new file mode 100644 index 0000000..01b6bb6 --- /dev/null +++ b/backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7c29b47..5eb5dbe 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 2581c29..01c6154 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -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= // Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day. router.get('/:id/calendar', async (req, res, next) => { diff --git a/backend/tests/experiments.test.js b/backend/tests/experiments.test.js index b9927f8..be586fd 100644 --- a/backend/tests/experiments.test.js +++ b/backend/tests/experiments.test.js @@ -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(); + }); +});