7 Commits

21 changed files with 772 additions and 208 deletions
+9 -1
View File
@@ -1,4 +1,6 @@
FROM node:20-alpine AS base FROM node:20-alpine AS base
# Prisma migration engine requires OpenSSL
RUN apk add --no-cache openssl
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --only=production RUN npm ci --only=production
@@ -9,6 +11,12 @@ COPY . .
CMD ["npm", "run", "dev"] CMD ["npm", "run", "dev"]
FROM base AS prod FROM base AS prod
# Install prisma CLI for migrate deploy at runtime
RUN npm install prisma --save-dev
COPY . . COPY . .
# Generate Prisma Client
RUN npx prisma generate RUN npx prisma generate
CMD ["npm", "start"] # Fix ownership so the node user can write engine cache
RUN chown -R node:node /app
USER node
CMD ["/bin/sh", "/app/docker-entrypoint.sh"]
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running Prisma migrations..."
npx prisma migrate deploy
echo "Starting server..."
exec node src/index.js
+39
View File
@@ -11,6 +11,7 @@
"@prisma/client": "^5.14.0", "@prisma/client": "^5.14.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-rate-limit": "^8.3.2",
"express-validator": "^7.1.0", "express-validator": "^7.1.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
@@ -2064,6 +2065,23 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-validator": { "node_modules/express-validator": {
"version": "7.3.2", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
@@ -2489,6 +2507,14 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -6396,6 +6422,14 @@
"vary": "~1.1.2" "vary": "~1.1.2"
} }
}, },
"express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"requires": {
"ip-address": "10.1.0"
}
},
"express-validator": { "express-validator": {
"version": "7.3.2", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
@@ -6697,6 +6731,11 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="
},
"ipaddr.js": { "ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+4 -1
View File
@@ -16,6 +16,7 @@
"@prisma/client": "^5.14.0", "@prisma/client": "^5.14.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-rate-limit": "^8.3.2",
"express-validator": "^7.1.0", "express-validator": "^7.1.0",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
@@ -29,7 +30,9 @@
}, },
"jest": { "jest": {
"testEnvironment": "node", "testEnvironment": "node",
"testMatch": ["**/tests/**/*.test.js"], "testMatch": [
"**/tests/**/*.test.js"
],
"globalSetup": "./tests/setup.js", "globalSetup": "./tests/setup.js",
"globalTeardown": "./tests/teardown.js" "globalTeardown": "./tests/teardown.js"
} }
@@ -0,0 +1,5 @@
-- Add template field to experiments (JSON array of field definitions)
ALTER TABLE "experiments" ADD COLUMN "template" JSONB NOT NULL DEFAULT '[]';
-- Add custom_fields to daily_statuses (stores values for non-builtin fields)
ALTER TABLE "daily_statuses" ADD COLUMN "custom_fields" JSONB;
+3
View File
@@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -11,6 +12,7 @@ model Experiment {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
created_at DateTime @default(now()) created_at DateTime @default(now())
template Json @default("[]")
animals Animal[] animals Animal[]
@@map("experiments") @@map("experiments")
@@ -35,6 +37,7 @@ model DailyStatus {
vitals String? vitals String?
treatment String? treatment String?
notes String? notes String?
custom_fields Json?
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade) animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
@@map("daily_statuses") @@map("daily_statuses")
+24 -2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet'); const helmet = require('helmet');
const morgan = require('morgan'); const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { globalErrorHandler } = require('./middleware/errorHandler'); const { globalErrorHandler } = require('./middleware/errorHandler');
const experimentsRouter = require('./routes/experiments'); const experimentsRouter = require('./routes/experiments');
@@ -11,19 +12,40 @@ const auditLogsRouter = require('./routes/auditLogs');
const app = express(); const app = express();
app.use(helmet()); // Security headers
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
// CORS — restrict to the configured frontend origin in production
const allowedOrigin = process.env.CORS_ORIGIN || '*';
app.use(cors({ app.use(cors({
origin: process.env.CORS_ORIGIN || '*', origin: allowedOrigin,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'], allowedHeaders: ['Content-Type', 'Authorization'],
})); }));
// Rate limiting — 100 requests per minute per IP on all API routes
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
skip: () => process.env.NODE_ENV === 'test',
});
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
app.use(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); app.use(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Health check (no rate limit)
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: 'ok', timestamp: new Date().toISOString() });
}); });
// Apply rate limiter to all API routes
app.use('/api', apiLimiter);
app.use('/api/experiments', experimentsRouter); app.use('/api/experiments', experimentsRouter);
app.use('/api/animals', animalsRouter); app.use('/api/animals', animalsRouter);
app.use('/api/daily-statuses', dailyStatusesRouter); app.use('/api/daily-statuses', dailyStatusesRouter);
+1 -6
View File
@@ -7,12 +7,7 @@ async function startServer() {
try { try {
await prisma.$connect(); await prisma.$connect();
console.log('Database connected successfully'); console.log('Database connected successfully');
// Note: migrations are run by docker-entrypoint.sh before this process starts
if (process.env.NODE_ENV === 'production') {
const { execSync } = require('child_process');
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
}
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);
}); });
+29
View File
@@ -0,0 +1,29 @@
/**
* The default daily-record template applied to every new experiment.
*
* 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 = [
{ 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 },
];
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, UUID_RE };
+1 -1
View File
@@ -7,7 +7,7 @@ const { validateRequest } = require('../middleware/errorHandler');
router.get( router.get(
'/', '/',
[ [
query('tableName').optional().isString(), query('tableName').optional().isIn(['experiments', 'animals', 'daily_statuses', 'audit_logs']).withMessage('tableName must be one of: experiments, animals, daily_statuses, audit_logs'),
query('recordId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('recordId must be a valid UUID'), query('recordId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('recordId must be a valid UUID'),
query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1200').toInt(), query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1200').toInt(),
query('offset').optional().isInt({ min: 0 }).withMessage('offset must be ≥0').toInt(), query('offset').optional().isInt({ min: 0 }).withMessage('offset must be ≥0').toInt(),
+18 -34
View File
@@ -11,6 +11,7 @@ const statusValidation = [
body('vitals').optional().isString(), body('vitals').optional().isString(),
body('treatment').optional().isString(), body('treatment').optional().isString(),
body('notes').optional().isString(), body('notes').optional().isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
]; ];
const statusUpdateValidation = [ const statusUpdateValidation = [
@@ -19,9 +20,10 @@ const statusUpdateValidation = [
body('vitals').optional().isString(), body('vitals').optional().isString(),
body('treatment').optional().isString(), body('treatment').optional().isString(),
body('notes').optional().isString(), body('notes').optional().isString(),
body('custom_fields').optional().isObject().withMessage('custom_fields must be an object'),
]; ];
// GET /api/daily-statuses?animalId=<uuid> — list for animal // GET /api/daily-statuses?animalId=<uuid>
router.get( router.get(
'/', '/',
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')], [query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
@@ -35,13 +37,11 @@ router.get(
include: { animal: { select: { animal_name: true, animal_id_string: true } } }, include: { animal: { select: { animal_name: true, animal_id_string: true } } },
}); });
res.json(statuses); res.json(statuses);
} catch (err) { } catch (err) { next(err); }
next(err);
}
} }
); );
// GET /api/daily-statuses/:id — get one // GET /api/daily-statuses/:id
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
const status = await prisma.dailyStatus.findUnique({ const status = await prisma.dailyStatus.findUnique({
@@ -50,15 +50,13 @@ router.get('/:id', async (req, res, next) => {
}); });
if (!status) return res.status(404).json({ error: 'Daily status not found' }); if (!status) return res.status(404).json({ error: 'Daily status not found' });
res.json(status); res.json(status);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
// POST /api/daily-statuses — create // POST /api/daily-statuses
router.post('/', statusValidation, validateRequest, async (req, res, next) => { router.post('/', statusValidation, validateRequest, async (req, res, next) => {
try { try {
const { animal_id, date, experiment_description, vitals, treatment, notes } = req.body; const { animal_id, date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
const status = await prisma.dailyStatus.create({ const status = await prisma.dailyStatus.create({
data: { data: {
animal_id, animal_id,
@@ -67,25 +65,17 @@ router.post('/', statusValidation, validateRequest, async (req, res, next) => {
vitals, vitals,
treatment, treatment,
notes, notes,
custom_fields: custom_fields ?? {},
}, },
}); });
await prisma.auditLog.create({ await prisma.auditLog.create({
data: { data: { table_name: 'daily_statuses', record_id: status.id, action: 'CREATE', changes: { before: null, after: status } },
table_name: 'daily_statuses',
record_id: status.id,
action: 'CREATE',
changes: { before: null, after: status },
},
}); });
res.status(201).json(status); res.status(201).json(status);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
// PUT /api/daily-statuses/:id — update // PUT /api/daily-statuses/:id
router.put( router.put(
'/:id', '/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus), auditMiddleware('daily_statuses', prisma.dailyStatus),
@@ -93,26 +83,22 @@ router.put(
validateRequest, validateRequest,
async (req, res, next) => { async (req, res, next) => {
try { try {
const { date, experiment_description, vitals, treatment, notes } = req.body; const { date, experiment_description, vitals, treatment, notes, custom_fields } = req.body;
const data = {}; const data = {};
if (date !== undefined) data.date = new Date(date); if (date !== undefined) data.date = new Date(date);
if (experiment_description !== undefined) data.experiment_description = experiment_description; if (experiment_description !== undefined) data.experiment_description = experiment_description;
if (vitals !== undefined) data.vitals = vitals; if (vitals !== undefined) data.vitals = vitals;
if (treatment !== undefined) data.treatment = treatment; if (treatment !== undefined) data.treatment = treatment;
if (notes !== undefined) data.notes = notes; if (notes !== undefined) data.notes = notes;
if (custom_fields !== undefined) data.custom_fields = custom_fields;
const status = await prisma.dailyStatus.update({ const status = await prisma.dailyStatus.update({ where: { id: req.params.id }, data });
where: { id: req.params.id },
data,
});
res.json(status); res.json(status);
} catch (err) { } catch (err) { next(err); }
next(err);
}
} }
); );
// DELETE /api/daily-statuses/:id — delete // DELETE /api/daily-statuses/:id
router.delete( router.delete(
'/:id', '/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus), auditMiddleware('daily_statuses', prisma.dailyStatus),
@@ -120,9 +106,7 @@ router.delete(
try { try {
await prisma.dailyStatus.delete({ where: { id: req.params.id } }); await prisma.dailyStatus.delete({ where: { id: req.params.id } });
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) { next(err); }
next(err);
}
} }
); );
+123 -33
View File
@@ -3,12 +3,77 @@ const { body } = require('express-validator');
const prisma = require('../lib/prisma'); const prisma = require('../lib/prisma');
const auditMiddleware = require('../middleware/auditMiddleware'); const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler'); const { validateRequest } = require('../middleware/errorHandler');
const { v4: uuidv4 } = require('uuid');
const { resolveTemplate, DEFAULT_TEMPLATE, UUID_RE } = require('../lib/defaultTemplate');
const experimentValidation = [ const experimentValidation = [
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'), body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
]; ];
// GET /api/experiments — list all // ── Template validation helpers ────────────────────────────────────────────────
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 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 (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`;
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)
return `custom field "${field.key}" must have builtin: false`;
}
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
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const experiments = await prisma.experiment.findMany({ const experiments = await prisma.experiment.findMany({
@@ -16,12 +81,10 @@ router.get('/', async (req, res, next) => {
include: { _count: { select: { animals: true } } }, include: { _count: { select: { animals: true } } },
}); });
res.json(experiments); res.json(experiments);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
// GET /api/experiments/:id — get one with animals // GET /api/experiments/:id
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
const experiment = await prisma.experiment.findUnique({ const experiment = await prisma.experiment.findUnique({
@@ -29,35 +92,25 @@ router.get('/:id', async (req, res, next) => {
include: { animals: true }, include: { animals: true },
}); });
if (!experiment) return res.status(404).json({ error: 'Experiment not found' }); if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
res.json(experiment); res.json(experiment);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
// POST /api/experiments — create // POST /api/experiments
router.post('/', experimentValidation, validateRequest, async (req, res, next) => { router.post('/', experimentValidation, validateRequest, async (req, res, next) => {
try { try {
const { title } = req.body; const { title } = req.body;
const experiment = await prisma.experiment.create({ data: { title } }); const experiment = await prisma.experiment.create({ data: { title } });
// Write CREATE audit log
await prisma.auditLog.create({ await prisma.auditLog.create({
data: { data: { table_name: 'experiments', record_id: experiment.id, action: 'CREATE', changes: { before: null, after: experiment } },
table_name: 'experiments',
record_id: experiment.id,
action: 'CREATE',
changes: { before: null, after: experiment },
},
}); });
experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
res.status(201).json(experiment); res.status(201).json(experiment);
} catch (err) { } catch (err) { next(err); }
next(err);
}
}); });
// PUT /api/experiments/:id — update // PUT /api/experiments/:id
router.put( router.put(
'/:id', '/:id',
auditMiddleware('experiments', prisma.experiment), auditMiddleware('experiments', prisma.experiment),
@@ -66,18 +119,14 @@ router.put(
async (req, res, next) => { async (req, res, next) => {
try { try {
const { title } = req.body; const { title } = req.body;
const experiment = await prisma.experiment.update({ const experiment = await prisma.experiment.update({ where: { id: req.params.id }, data: { title } });
where: { id: req.params.id }, experiment.template = ensureFieldIds(resolveTemplate(experiment.template));
data: { title },
});
res.json(experiment); res.json(experiment);
} catch (err) { } catch (err) { next(err); }
next(err);
}
} }
); );
// DELETE /api/experiments/:id — delete // DELETE /api/experiments/:id
router.delete( router.delete(
'/:id', '/:id',
auditMiddleware('experiments', prisma.experiment), auditMiddleware('experiments', prisma.experiment),
@@ -85,10 +134,51 @@ router.delete(
try { try {
await prisma.experiment.delete({ where: { id: req.params.id } }); await prisma.experiment.delete({ where: { id: req.params.id } });
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) { next(err); }
next(err);
}
} }
); );
// ── Template endpoints ─────────────────────────────────────────────────────────
// GET /api/experiments/:id/template
router.get('/:id/template', async (req, res, next) => {
try {
const experiment = await prisma.experiment.findUnique({
where: { id: req.params.id },
select: { id: true, template: true },
});
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
res.json(ensureFieldIds(resolveTemplate(experiment.template)));
} catch (err) { next(err); }
});
// PUT /api/experiments/:id/template
router.put('/:id/template', 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 template = req.body;
const validationError = validateTemplate(template);
if (validationError) return res.status(422).json({ error: validationError });
const before = ensureFieldIds(resolveTemplate(experiment.template));
const updated = await prisma.experiment.update({
where: { id: req.params.id },
data: { template },
});
await prisma.auditLog.create({
data: {
table_name: 'experiments',
record_id: experiment.id,
action: 'UPDATE',
changes: { before: { template: before }, after: { template } },
},
});
res.json(template);
} catch (err) { next(err); }
});
module.exports = router; module.exports = router;
+5
View File
@@ -38,6 +38,11 @@ describe('GET /api/audit-logs', () => {
); );
}); });
it('returns 422 for invalid tableName (not in allowlist)', async () => {
const res = await request(app).get('/api/audit-logs?tableName=users');
expect(res.status).toBe(422);
});
it('returns 422 for invalid recordId', async () => { it('returns 422 for invalid recordId', async () => {
const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid'); const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid');
expect(res.status).toBe(422); expect(res.status).toBe(422);
+10 -5
View File
@@ -11,8 +11,9 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-experiments_db} POSTGRES_DB: ${POSTGRES_DB:-experiments_db}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: # Port NOT exposed to host — only reachable by backend via internal Docker network
- "5432:5432" expose:
- "5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-expuser} -d ${POSTGRES_DB:-experiments_db}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-expuser} -d ${POSTGRES_DB:-experiments_db}"]
interval: 10s interval: 10s
@@ -32,13 +33,17 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-expuser}:${POSTGRES_PASSWORD:-exppassword}@postgres:5432/${POSTGRES_DB:-experiments_db} DATABASE_URL: postgresql://${POSTGRES_USER:-expuser}:${POSTGRES_PASSWORD:-exppassword}@postgres:5432/${POSTGRES_DB:-experiments_db}
NODE_ENV: production NODE_ENV: production
PORT: 3001 PORT: 3001
CORS_ORIGIN: "http://localhost:52867"
# Non-root user set in Dockerfile; entrypoint runs migrations then starts server
entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"]
ports: ports:
- "3001:3001" - "3001:3001"
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
interval: 15s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 10
start_period: 40s
frontend: frontend:
build: build:
+2
View File
@@ -29,6 +29,8 @@ export const experimentsApi = {
create: (data) => api.post('/experiments', data).then((r) => r.data), create: (data) => api.post('/experiments', data).then((r) => r.data),
update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data), update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/experiments/${id}`), delete: (id) => api.delete(`/experiments/${id}`),
getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data),
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
}; };
// ── Animals ─────────────────────────────────────────────────────────────────── // ── Animals ───────────────────────────────────────────────────────────────────
+86 -86
View File
@@ -5,42 +5,49 @@ import FormField, { Input, Textarea } from './ui/FormField';
import Alert from './ui/Alert'; import Alert from './ui/Alert';
import { dailyStatusesApi } from '../api/client'; import { dailyStatusesApi } from '../api/client';
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) { const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
/** 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.fieldId] ?? '';
}
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) {
const today = format(new Date(), 'yyyy-MM-dd'); const today = format(new Date(), 'yyyy-MM-dd');
const [fields, setFields] = useState({
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today, // Active fields from template (date is always first and handled separately)
experiment_description: existing?.experiment_description ?? '', const activeFields = template.filter((f) => f.active);
vitals: existing?.vitals ?? '',
treatment: existing?.treatment ?? '', function buildInitialValues() {
notes: existing?.notes ?? '', const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today };
}); for (const f of activeFields) {
vals[f.key] = getValue(existing, f);
}
return vals;
}
const [values, setValues] = useState(buildInitialValues);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [apiError, setApiError] = useState(null); const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
setFields({ setValues(buildInitialValues());
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
experiment_description: existing?.experiment_description ?? '',
vitals: existing?.vitals ?? '',
treatment: existing?.treatment ?? '',
notes: existing?.notes ?? '',
});
setErrors({}); setErrors({});
setApiError(null); setApiError(null);
}, [existing]); }, [existing, template]);
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value })); const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
function validate() { function validate() {
const e = {}; const e = {};
if (!fields.date) { if (!values.date) {
e.date = 'Date is required'; e.date = 'Date is required';
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) { } else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) || isNaN(new Date(values.date).getTime())) {
e.date = 'Date must be in YYYY-MM-DD format'; e.date = 'Date must be a valid YYYY-MM-DD date';
} else {
const d = new Date(fields.date);
if (isNaN(d.getTime())) e.date = 'Invalid date';
} }
setErrors(e); setErrors(e);
return Object.keys(e).length === 0; return Object.keys(e).length === 0;
@@ -52,16 +59,31 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
setLoading(true); setLoading(true);
setApiError(null); setApiError(null);
try { try {
const payload = { // Separate builtin values from custom_fields
date: fields.date, const builtin = {};
experiment_description: fields.experiment_description || null, const custom = {};
vitals: fields.vitals || null, for (const f of activeFields) {
treatment: fields.treatment || null, const val = values[f.key] || null;
notes: fields.notes || null, 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;
}
}
// 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 };
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
const result = existing const result = existing
? await dailyStatusesApi.update(existing.id, payload) ? await dailyStatusesApi.update(existing.id, payload)
: await dailyStatusesApi.create({ ...payload, animal_id: animalId }); : await dailyStatusesApi.create({ ...payload, animal_id: animalId });
onSuccess(result); onSuccess(result);
} catch (err) { } catch (err) {
setApiError(err); setApiError(err);
@@ -73,66 +95,44 @@ export default function DailyStatusForm({ existing, animalId, onSuccess, onCance
return ( return (
<form onSubmit={handleSubmit} noValidate className="space-y-4"> <form onSubmit={handleSubmit} noValidate className="space-y-4">
{apiError && ( {apiError && (
<Alert <Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
type="error" )}
message={apiError.message}
details={apiError.details} {/* Date — always present */}
onDismiss={() => setApiError(null)} <FormField label="Date" name="date" error={errors.date} required>
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
</FormField>
{/* Dynamic fields from template */}
{activeFields.map((field) => (
<FormField key={field.key} label={field.label} name={field.key}>
{field.type === 'textarea' ? (
<Textarea
name={field.key}
value={values[field.key] ?? ''}
onChange={set(field.key)}
disabled={loading}
/>
) : (
<Input
name={field.key}
value={values[field.key] ?? ''}
onChange={set(field.key)}
disabled={loading}
/> />
)} )}
<FormField label="Date" name="date" error={errors.date} required>
<Input
type="date"
name="date"
value={fields.date}
onChange={set('date')}
required
disabled={loading}
/>
</FormField>
<FormField label="Experiment Description" name="experiment_description">
<Textarea
name="experiment_description"
value={fields.experiment_description}
onChange={set('experiment_description')}
placeholder="Describe the experimental conditions…"
disabled={loading}
/>
</FormField>
<FormField label="Vitals" name="vitals">
<Input
name="vitals"
value={fields.vitals}
onChange={set('vitals')}
placeholder="e.g. HR 72bpm, Temp 37.2°C, Weight 280g"
disabled={loading}
/>
</FormField>
<FormField label="Treatment" name="treatment">
<Input
name="treatment"
value={fields.treatment}
onChange={set('treatment')}
placeholder="e.g. Drug X — 10mg/kg oral"
disabled={loading}
/>
</FormField>
<FormField label="Notes" name="notes">
<Textarea
name="notes"
value={fields.notes}
onChange={set('notes')}
placeholder="Any additional observations…"
disabled={loading}
/>
</FormField> </FormField>
))}
{activeFields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-2">
No fields configured. Edit the experiment's Record Template to add fields.
</p>
)}
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}> <Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
Cancel <Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
</Button>
<Button type="submit" loading={loading}>
{existing ? 'Save Changes' : 'Log Status'}
</Button>
</div> </div>
</form> </form>
); );
+233
View File
@@ -0,0 +1,233 @@
import React, { useState, useEffect } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
// Slugify a label into a valid field key
function toKey(label) {
return label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
const [labelDraft, setLabelDraft] = useState(field.label);
const [labelError, setLabelError] = useState('');
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
function commitLabel() {
const trimmed = labelDraft.trim();
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
// For custom fields, also ensure the derived key is unique
if (!field.builtin) {
const newKey = toKey(trimmed);
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
const duplicate = allKeys.find((k) => k !== field.key && k === newKey);
if (duplicate) { setLabelError('A field with this name already exists'); return; }
}
setLabelError('');
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
}
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${field.active ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-100 opacity-60'}`}>
{/* Drag handle placeholder (visual only) */}
<span className="text-gray-300 cursor-default select-none text-lg leading-none"></span>
{/* Label input */}
<div className="flex-1 min-w-0">
<input
aria-label={`Field label for ${field.key}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
value={labelDraft}
disabled={!field.active}
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
onBlur={commitLabel}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
/>
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
<p className="text-xs text-gray-400 mt-0.5 font-mono">
key: {field.key}
{field.fieldId && (
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
· id: {field.fieldId.slice(0, 8)}
</span>
)}
</p>
</div>
{/* Type badge */}
<select
aria-label={`Field type for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.type}
disabled={!field.active}
onChange={(e) => onChange({ ...field, type: e.target.value })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
{/* Toggle active */}
<button
title={field.active ? 'Hide field' : 'Show field'}
aria-label={field.active ? `Hide field ${field.label}` : `Show field ${field.label}`}
onClick={() => onToggle(field.key)}
className={`text-sm px-2 py-1 rounded border transition-colors ${
field.active
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
: 'border-green-200 text-green-600 hover:bg-green-50'
}`}
>
{field.active ? 'Hide' : 'Show'}
</button>
{/* Remove — only for custom fields */}
{!field.builtin && (
<button
title="Remove field permanently"
aria-label={`Remove field ${field.label}`}
onClick={() => onRemove(field.key)}
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
>
</button>
)}
</div>
);
}
export default function TemplateEditor({ experimentId, onClose }) {
const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [newType, setNewType] = useState('text');
const [addError, setAddError] = useState('');
useEffect(() => {
experimentsApi.getTemplate(experimentId)
.then(setFields)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [experimentId]);
function updateField(updated) {
setFields((prev) => prev.map((f) => f.key === updated.key ? updated : f));
}
function toggleField(key) {
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
}
function removeField(key) {
setFields((prev) => prev.filter((f) => f.key !== key));
}
function addField() {
const trimmed = newLabel.trim();
if (!trimmed) { setAddError('Field name is required'); return; }
const key = toKey(trimmed);
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('');
const fieldId = crypto.randomUUID();
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true }]);
setNewLabel('');
setNewType('text');
}
async function save() {
setSaving(true);
setError(null);
try {
await experimentsApi.updateTemplate(experimentId, fields);
setSuccess(true);
setTimeout(() => { setSuccess(false); onClose(fields); }, 800);
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
}
const allKeys = fields.map((f) => f.key);
return (
<div className="space-y-4">
{loading && <p className="text-sm text-gray-400">Loading template</p>}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{success && <Alert type="success" message="Template saved!" />}
{!loading && (
<>
{/* Field list */}
<div className="space-y-2">
{fields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
)}
{fields.map((field) => (
<FieldRow
key={field.key}
field={field}
onChange={updateField}
onToggle={toggleField}
onRemove={removeField}
allKeys={allKeys}
/>
))}
</div>
{/* Add new field */}
<div className="border-t pt-4">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Add New Field</p>
<div className="flex gap-2 items-start">
<div className="flex-1">
<input
aria-label="New field name"
className={`w-full text-sm rounded border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 ${addError ? 'border-red-400' : 'border-gray-300'}`}
placeholder="Field name, e.g. Blood Pressure"
value={newLabel}
onChange={(e) => { setNewLabel(e.target.value); setAddError(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addField(); } }}
/>
{addError && <p className="text-xs text-red-500 mt-0.5">{addError}</p>}
</div>
<select
aria-label="New field type"
className="text-sm border border-gray-300 rounded px-2 py-2 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400"
value={newType}
onChange={(e) => setNewType(e.target.value)}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
<Button size="sm" onClick={addField} type="button">+ Add</Button>
</div>
</div>
{/* Info note */}
<p className="text-xs text-gray-400">
<strong>Hide</strong> removes a builtin field from forms while preserving its data.
<strong className="ml-1"></strong> permanently removes a custom field (data in existing records is not deleted).
</p>
{/* Actions */}
<div className="flex justify-end gap-3 border-t pt-4">
<Button variant="secondary" onClick={() => onClose(null)} disabled={saving}>Cancel</Button>
<Button onClick={save} loading={saving}>Save Template</Button>
</div>
</>
)}
</div>
);
}
+52 -13
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
import { animalsApi, dailyStatusesApi } from '../api/client'; import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
@@ -9,12 +9,21 @@ import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert'; import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection'; import AuditLogSection from '../components/AuditLogSection';
/** 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.fieldId];
}
export default function AnimalDetail() { export default function AnimalDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [animal, setAnimal] = useState(null); const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]); const [statuses, setStatuses] = useState([]);
const [template, setTemplate] = useState(location.state?.template ?? []);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null); const [successMsg, setSuccessMsg] = useState(null);
@@ -34,6 +43,12 @@ export default function AnimalDetail() {
]); ]);
setAnimal(animalData); setAnimal(animalData);
setStatuses(statusData); setStatuses(statusData);
// Fetch template if not passed via navigation state
if (!location.state?.template) {
const tmpl = await experimentsApi.getTemplate(animalData.experiment_id);
setTemplate(tmpl);
}
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -70,7 +85,7 @@ export default function AnimalDetail() {
} }
} }
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>; if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return ( if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} /> <Alert type="error" message={error} />
@@ -78,15 +93,15 @@ export default function AnimalDetail() {
</div> </div>
); );
const activeFields = template.filter((f) => f.active);
return ( return (
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4"> <nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link> <Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span> <span className="mx-2">/</span>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600"> <Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
Experiment
</Link>
<span className="mx-2">/</span> <span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{animal.animal_name}</span> <span className="text-gray-900 font-medium">{animal.animal_name}</span>
</nav> </nav>
@@ -124,22 +139,40 @@ export default function AnimalDetail() {
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button> <Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
</div> </div>
</div> </div>
{activeFields.length > 0 ? (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{activeFields
.map((f) => ({ field: f, value: getFieldValue(s, f) }))
.filter(({ value }) => value)
.map(({ field, value }) => (
<div key={field.key} className={field.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{field.label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
) : null}
{/* Fallback: show raw builtin fields if template is empty */}
{activeFields.length === 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm"> <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{[ {[
['Experiment Description', s.experiment_description], ['Experiment Description', s.experiment_description],
['Vitals', s.vitals], ['Vitals', s.vitals],
['Treatment', s.treatment], ['Treatment', s.treatment],
['Notes', s.notes], ['Notes', s.notes],
] ].filter(([, v]) => v).map(([label, value]) => (
.filter(([, v]) => v) <div key={label}>
.map(([label, value]) => (
<div key={label} className="flex flex-col">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt> <dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd> <dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div> </div>
))} ))}
</dl> </dl>
{!s.experiment_description && !s.vitals && !s.treatment && !s.notes && ( )}
{activeFields.length > 0 &&
activeFields.every((f) => !getFieldValue(s, f)) && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p> <p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
)} )}
</div> </div>
@@ -147,16 +180,22 @@ export default function AnimalDetail() {
</div> </div>
)} )}
<AuditLogSection tableName="daily_statuses" recordId={undefined} /> <AuditLogSection tableName="daily_statuses" />
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg"> <Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
<DailyStatusForm animalId={id} onSuccess={handleStatusSaved} onCancel={() => setShowAddStatus(false)} /> <DailyStatusForm
animalId={id}
template={template}
onSuccess={handleStatusSaved}
onCancel={() => setShowAddStatus(false)}
/>
</Modal> </Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg"> <Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
<DailyStatusForm <DailyStatusForm
existing={editStatus} existing={editStatus}
animalId={id} animalId={id}
template={template}
onSuccess={handleStatusSaved} onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)} onCancel={() => setEditStatus(null)}
/> />
+1 -1
View File
@@ -83,7 +83,7 @@ export default function Dashboard() {
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />} {error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{loading && ( {loading && (
<div className="text-center py-16 text-gray-400">Loading experiments</div> <div className="text-center py-16 text-gray-400" aria-live="polite" aria-busy="true">Loading experiments</div>
)} )}
{!loading && experiments.length === 0 && ( {!loading && experiments.length === 0 && (
+52 -9
View File
@@ -5,6 +5,7 @@ import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import AnimalForm from '../components/AnimalForm'; import AnimalForm from '../components/AnimalForm';
import TemplateEditor from '../components/TemplateEditor';
import Alert from '../components/ui/Alert'; import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection'; import AuditLogSection from '../components/AuditLogSection';
@@ -22,6 +23,7 @@ export default function ExperimentDetail() {
const [editAnimal, setEditAnimal] = useState(null); const [editAnimal, setEditAnimal] = useState(null);
const [deleteAnimal, setDeleteAnimal] = useState(null); const [deleteAnimal, setDeleteAnimal] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
const [showTemplate, setShowTemplate] = useState(false);
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -69,7 +71,16 @@ export default function ExperimentDetail() {
} }
} }
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>; function handleTemplateSaved(updatedTemplate) {
setShowTemplate(false);
if (updatedTemplate) {
// Update local experiment state so everything downstream re-renders
setExperiment((prev) => ({ ...prev, template: updatedTemplate }));
flash('Record template updated.');
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return ( if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8"> <div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} /> <Alert type="error" message={error} />
@@ -93,12 +104,44 @@ export default function ExperimentDetail() {
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled {animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
</p> </p>
</div> </div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowTemplate(true)}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Record Template
</Button>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button> <Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div> </div>
</div>
{successMsg && <Alert type="success" message={successMsg} />} {successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />} {error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Template summary strip */}
{experiment.template && experiment.template.length > 0 && (
<div className="mb-5 flex flex-wrap gap-1.5">
{experiment.template.map((f) => (
<span
key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
f.active ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-gray-100 border-gray-200 text-gray-400'
}`}
>
{f.active ? null : <span title="hidden"></span>}
{f.label}
</span>
))}
<button
onClick={() => setShowTemplate(true)}
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors"
>
edit
</button>
</div>
)}
{animals.length === 0 ? ( {animals.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl"> <div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p> <p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p>
@@ -110,7 +153,7 @@ export default function ExperimentDetail() {
<div <div
key={animal.id} key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group" className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/animals/${animal.id}`)} onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
> >
<div> <div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors"> <div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
@@ -129,19 +172,19 @@ export default function ExperimentDetail() {
</div> </div>
)} )}
<AuditLogSection tableName="animals" recordId={undefined} /> <AuditLogSection tableName="animals" />
{/* Template editor modal */}
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg">
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
</Modal>
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal"> <Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} /> <AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
</Modal> </Modal>
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal"> <Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
<AnimalForm <AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
existing={editAnimal}
experimentId={id}
onSuccess={handleAnimalSaved}
onCancel={() => setEditAnimal(null)}
/>
</Modal> </Modal>
<ConfirmDialog <ConfirmDialog
+52 -1
View File
@@ -123,4 +123,55 @@ All PUT and DELETE routes pass through `auditMiddleware` which:
- All tests run inside Docker via `docker-compose.test.yml` - All tests run inside Docker via `docker-compose.test.yml`
## Red Team Audit ## Red Team Audit
_To be filled after Phase 3 testing is complete._
### Findings
#### 🔴 Critical
1. **CORS wildcard (`origin: '*'`)** — The backend accepts requests from any origin. An attacker on a different domain could make authenticated browser requests to the API if any auth is added later, or exfiltrate data via CSRF.
- **Fix**: Restrict `CORS_ORIGIN` to the frontend's origin in production.
2. **Prisma `migrate deploy` via `execSync` on server start** — Running migrations inline at startup means a crashing migration kills the entire service process. Also, injecting `DATABASE_URL` with special characters could theoretically escape the shell via `child_process.execSync`.
- **Fix**: Run migrations as a separate Docker entrypoint step, not inside the Node process.
3. **Unparameterized `table_name` in audit logs route**`table_name` is accepted as a plain string from query params and passed directly to Prisma. Prisma ORM prevents SQL injection here, but the field is never validated against a known allowlist — an attacker can write arbitrary strings to query params and pollute logs.
- **Fix**: Validate `tableName` against an allowlist: `['experiments', 'animals', 'daily_statuses', 'audit_logs']`.
#### 🟡 Medium
4. **No rate limiting** — All API endpoints are open to brute-force or denial-of-service via request flooding. Express has no rate limiter middleware.
- **Fix**: Add `express-rate-limit` on all `/api/*` routes.
5. **Helmet defaults only**`helmet()` is enabled but with defaults; notably `Content-Security-Policy` is not tuned for the SPA. The nginx proxy serves the frontend without explicit CSP headers either.
- **Fix**: Add a CSP header in nginx and strengthen helmet config.
6. **Docker: no `read_only` filesystem or user restriction** — The backend and frontend containers run as root by default.
- **Fix**: Add `user: node` and `read_only: true` (with tmpfs for writable paths) in `docker-compose.yml`.
7. **Postgres port exposed on host**`docker-compose.yml` exposes port 5432 externally. In production, only the backend container needs DB access.
- **Fix**: Remove host-side port mapping for postgres; use internal Docker networking only.
8. **`NODE_ENV=production` skip for Prisma generate** — `npx prisma generate` is not called in the production Dockerfile before `npm ci --only=production`, so the generated client may be missing.
- **Fix**: Run `prisma generate` in the builder stage.
#### 🟢 UX / Low
9. **Audit log section on Dashboard shows ALL experiments audit logs** (no `recordId` filter) — this is intentional but confusing; a large table will render hundreds of rows without pagination controls.
- **Fix**: Add pagination controls to `AuditLogSection`.
10. **No loading skeleton on list pages** — a "Loading…" text string isn't accessible; screen readers get no meaningful feedback.
- **Fix**: Add `aria-live="polite"` region for loading state.
11. **Form submission does not disable all interactive elements** — While the submit button shows a spinner, the Cancel button remains active and can unmount the form mid-submission, causing a React state update on an unmounted component.
- **Fix**: Disable Cancel button while `loading` is true (already done for inputs, extend to Cancel).
### Patches Applied (feature/red-team-fixes)
- Restricted CORS to env-configurable origin
- Moved migration out of `startServer()` into Docker entrypoint script
- Added `tableName` allowlist validation on audit logs route
- Added `express-rate-limit` (100 req/min per IP on all API routes)
- Removed exposed postgres port from docker-compose
- Added `user: node` to backend docker-compose service
- Fixed Prisma generate in production Dockerfile
- Disabled Cancel button during form submission