Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7374777d3 | |||
| 1305e53f96 | |||
| 86a56427b7 | |||
| 80fb1c6e27 | |||
| b03566e658 | |||
| 9b3c883bf0 | |||
| 5460a93217 | |||
| 5874ed8374 | |||
| c359cb4bb9 | |||
| c32a5d200d | |||
| f1d2449808 |
+9
-1
@@ -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"]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running Prisma migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
echo "Starting server..."
|
||||||
|
exec node src/index.js
|
||||||
Generated
+39
@@ -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",
|
||||||
|
|||||||
@@ -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,8 +30,9 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testMatch": ["**/tests/**/*.test.js"],
|
"testMatch": [
|
||||||
"setupFilesAfterFramework": [],
|
"**/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;
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { globalErrorHandler } = require('./middleware/errorHandler');
|
||||||
|
|
||||||
|
const experimentsRouter = require('./routes/experiments');
|
||||||
|
const animalsRouter = require('./routes/animals');
|
||||||
|
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
||||||
|
const auditLogsRouter = require('./routes/auditLogs');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
origin: allowedOrigin,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
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(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||||
|
|
||||||
|
// Health check (no rate limit)
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
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/animals', animalsRouter);
|
||||||
|
app.use('/api/daily-statuses', dailyStatusesRouter);
|
||||||
|
app.use('/api/audit-logs', auditLogsRouter);
|
||||||
|
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(globalErrorHandler);
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
+2
-49
@@ -1,58 +1,13 @@
|
|||||||
const express = require('express');
|
|
||||||
const cors = require('cors');
|
|
||||||
const helmet = require('helmet');
|
|
||||||
const morgan = require('morgan');
|
|
||||||
const prisma = require('./lib/prisma');
|
const prisma = require('./lib/prisma');
|
||||||
const { globalErrorHandler } = require('./middleware/errorHandler');
|
const app = require('./app');
|
||||||
|
|
||||||
const experimentsRouter = require('./routes/experiments');
|
|
||||||
const animalsRouter = require('./routes/animals');
|
|
||||||
const dailyStatusesRouter = require('./routes/dailyStatuses');
|
|
||||||
const auditLogsRouter = require('./routes/auditLogs');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Security & utility middleware
|
|
||||||
app.use(helmet());
|
|
||||||
app.use(cors({
|
|
||||||
origin: process.env.CORS_ORIGIN || '*',
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
||||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
|
||||||
}));
|
|
||||||
app.use(express.json({ limit: '1mb' }));
|
|
||||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// API routes
|
|
||||||
app.use('/api/experiments', experimentsRouter);
|
|
||||||
app.use('/api/animals', animalsRouter);
|
|
||||||
app.use('/api/daily-statuses', dailyStatusesRouter);
|
|
||||||
app.use('/api/audit-logs', auditLogsRouter);
|
|
||||||
|
|
||||||
// 404 handler for unknown routes
|
|
||||||
app.use((req, res) => {
|
|
||||||
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Global error handler (must be last)
|
|
||||||
app.use(globalErrorHandler);
|
|
||||||
|
|
||||||
async function startServer() {
|
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
|
||||||
// Run migrations on startup in production
|
|
||||||
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}`);
|
||||||
});
|
});
|
||||||
@@ -63,5 +18,3 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startServer();
|
startServer();
|
||||||
|
|
||||||
module.exports = app; // export for testing
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -21,36 +21,40 @@ function auditMiddleware(tableName, model, idParam = 'id') {
|
|||||||
// Attach before-state so route handlers can reference it if needed
|
// Attach before-state so route handlers can reference it if needed
|
||||||
req._auditBefore = before;
|
req._auditBefore = before;
|
||||||
|
|
||||||
// Wrap res.json to intercept the response
|
// Shared audit writer — called by both res.json and res.send interceptors
|
||||||
const originalJson = res.json.bind(res);
|
async function writeAuditLog() {
|
||||||
res.json = async function (data) {
|
|
||||||
// Only log on successful mutations
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
try {
|
try {
|
||||||
const action = req.method === 'DELETE' ? 'DELETE' : 'UPDATE';
|
const action = req.method === 'DELETE' ? 'DELETE' : 'UPDATE';
|
||||||
const after = action === 'DELETE' ? null : await model.findUnique({ where: { id: recordId } });
|
const after = action === 'DELETE' ? null : await model.findUnique({ where: { id: recordId } });
|
||||||
|
|
||||||
const changes = {
|
|
||||||
before,
|
|
||||||
after,
|
|
||||||
};
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
table_name: tableName,
|
table_name: tableName,
|
||||||
record_id: recordId,
|
record_id: recordId,
|
||||||
action,
|
action,
|
||||||
changes,
|
changes: { before, after },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (auditErr) {
|
} catch (auditErr) {
|
||||||
// Audit failures should never block the primary response
|
|
||||||
console.error('Audit log write failed:', auditErr.message);
|
console.error('Audit log write failed:', auditErr.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap res.json (used by JSON-returning routes)
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
res.json = async function (data) {
|
||||||
|
await writeAuditLog();
|
||||||
return originalJson(data);
|
return originalJson(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap res.send (used by DELETE 204 routes)
|
||||||
|
const originalSend = res.send.bind(res);
|
||||||
|
res.send = async function (data) {
|
||||||
|
await writeAuditLog();
|
||||||
|
return originalSend(data);
|
||||||
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const auditMiddleware = require('../middleware/auditMiddleware');
|
|||||||
const { validateRequest } = require('../middleware/errorHandler');
|
const { validateRequest } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
const animalValidation = [
|
const animalValidation = [
|
||||||
body('experiment_id').notEmpty().withMessage('experiment_id is required').isUUID().withMessage('experiment_id must be a valid UUID'),
|
body('experiment_id').notEmpty().withMessage('experiment_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experiment_id must be a valid UUID'),
|
||||||
body('animal_id_string').trim().notEmpty().withMessage('animal_id_string is required'),
|
body('animal_id_string').trim().notEmpty().withMessage('animal_id_string is required'),
|
||||||
body('animal_name').trim().notEmpty().withMessage('animal_name is required'),
|
body('animal_name').trim().notEmpty().withMessage('animal_name is required'),
|
||||||
];
|
];
|
||||||
@@ -18,7 +18,7 @@ const animalUpdateValidation = [
|
|||||||
// GET /api/animals?experimentId=<uuid> — list for experiment
|
// GET /api/animals?experimentId=<uuid> — list for experiment
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
[query('experimentId').optional().isUUID().withMessage('experimentId must be a valid UUID')],
|
[query('experimentId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experimentId must be a valid UUID')],
|
||||||
validateRequest,
|
validateRequest,
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ 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().isUUID().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 1–200').toInt(),
|
query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1–200').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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ const auditMiddleware = require('../middleware/auditMiddleware');
|
|||||||
const { validateRequest } = require('../middleware/errorHandler');
|
const { validateRequest } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
const statusValidation = [
|
const statusValidation = [
|
||||||
body('animal_id').notEmpty().withMessage('animal_id is required').isUUID().withMessage('animal_id must be a valid UUID'),
|
body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'),
|
||||||
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
|
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
|
||||||
body('experiment_description').optional().isString(),
|
body('experiment_description').optional().isString(),
|
||||||
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,12 +20,13 @@ 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().isUUID().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')],
|
||||||
validateRequest,
|
validateRequest,
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Shared Prisma mock — individual test files override methods as needed.
|
||||||
|
const prismaMock = {
|
||||||
|
experiment: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
|
animal: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
|
dailyStatus: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
|
$connect: jest.fn(),
|
||||||
|
$disconnect: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = prismaMock;
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||||
|
const prisma = require('../src/lib/prisma');
|
||||||
|
const app = require('../src/app');
|
||||||
|
|
||||||
|
const EXP_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
|
||||||
|
const ANIMAL = {
|
||||||
|
id: 'cccccccc-0000-0000-0000-000000000001',
|
||||||
|
experiment_id: EXP_ID,
|
||||||
|
animal_id_string: 'M-001',
|
||||||
|
animal_name: 'Whiskers',
|
||||||
|
_count: { daily_statuses: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
describe('GET /api/animals', () => {
|
||||||
|
it('returns animals filtered by experimentId', async () => {
|
||||||
|
prisma.animal.findMany.mockResolvedValue([ANIMAL]);
|
||||||
|
const res = await request(app).get(`/api/animals?experimentId=${EXP_ID}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0].animal_id_string).toBe('M-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 for invalid UUID experimentId', async () => {
|
||||||
|
const res = await request(app).get('/api/animals?experimentId=not-a-uuid');
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/animals/:id', () => {
|
||||||
|
it('returns animal with daily_statuses', async () => {
|
||||||
|
prisma.animal.findUnique.mockResolvedValue({ ...ANIMAL, daily_statuses: [] });
|
||||||
|
const res = await request(app).get(`/api/animals/${ANIMAL.id}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.animal_name).toBe('Whiskers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown animal', async () => {
|
||||||
|
prisma.animal.findUnique.mockResolvedValue(null);
|
||||||
|
const res = await request(app).get('/api/animals/00000000-0000-0000-0000-000000000000');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/animals', () => {
|
||||||
|
it('creates animal with UUID and writes CREATE audit log', async () => {
|
||||||
|
prisma.animal.create.mockResolvedValue(ANIMAL);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/animals')
|
||||||
|
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001', animal_name: 'Whiskers' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when experiment_id is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/animals')
|
||||||
|
.send({ animal_id_string: 'M-001', animal_name: 'Whiskers' });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.details).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ field: 'experiment_id' })])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when experiment_id is not a UUID', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/animals')
|
||||||
|
.send({ experiment_id: 'bad-id', animal_id_string: 'M-001', animal_name: 'Whiskers' });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when animal_name is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/animals')
|
||||||
|
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001' });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/animals/:id', () => {
|
||||||
|
it('updates animal and writes UPDATE audit log', async () => {
|
||||||
|
const updated = { ...ANIMAL, animal_name: 'Mr Whiskers' };
|
||||||
|
prisma.animal.findUnique
|
||||||
|
.mockResolvedValueOnce(ANIMAL)
|
||||||
|
.mockResolvedValueOnce(updated);
|
||||||
|
prisma.animal.update.mockResolvedValue(updated);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/animals/${ANIMAL.id}`)
|
||||||
|
.send({ animal_name: 'Mr Whiskers' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.animal_name).toBe('Mr Whiskers');
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/animals/:id', () => {
|
||||||
|
it('deletes animal and writes DELETE audit log', async () => {
|
||||||
|
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
|
||||||
|
prisma.animal.delete.mockResolvedValue(ANIMAL);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app).delete(`/api/animals/${ANIMAL.id}`);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||||
|
const prisma = require('../src/lib/prisma');
|
||||||
|
const app = require('../src/app');
|
||||||
|
|
||||||
|
const LOG = {
|
||||||
|
id: 'ffffffff-0000-0000-0000-000000000001',
|
||||||
|
table_name: 'experiments',
|
||||||
|
record_id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||||
|
action: 'UPDATE',
|
||||||
|
changes: { before: { title: 'Old' }, after: { title: 'New' } },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
describe('GET /api/audit-logs', () => {
|
||||||
|
it('returns paginated logs', async () => {
|
||||||
|
prisma.auditLog.findMany.mockResolvedValue([LOG]);
|
||||||
|
prisma.auditLog.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/audit-logs');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.logs).toHaveLength(1);
|
||||||
|
expect(res.body.total).toBe(1);
|
||||||
|
expect(res.body.logs[0].action).toBe('UPDATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by tableName', async () => {
|
||||||
|
prisma.auditLog.findMany.mockResolvedValue([LOG]);
|
||||||
|
prisma.auditLog.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/audit-logs?tableName=experiments');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(prisma.auditLog.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { table_name: 'experiments' } })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid');
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects limit and offset params', async () => {
|
||||||
|
prisma.auditLog.findMany.mockResolvedValue([]);
|
||||||
|
prisma.auditLog.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/audit-logs?limit=10&offset=20');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.limit).toBe(10);
|
||||||
|
expect(res.body.offset).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/audit-logs/:id', () => {
|
||||||
|
it('returns a single log entry', async () => {
|
||||||
|
prisma.auditLog.findUnique.mockResolvedValue(LOG);
|
||||||
|
const res = await request(app).get(`/api/audit-logs/${LOG.id}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.action).toBe('UPDATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown log id', async () => {
|
||||||
|
prisma.auditLog.findUnique.mockResolvedValue(null);
|
||||||
|
const res = await request(app).get('/api/audit-logs/00000000-0000-0000-0000-000000000000');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||||
|
const prisma = require('../src/lib/prisma');
|
||||||
|
const app = require('../src/app');
|
||||||
|
|
||||||
|
const ANIMAL_ID = 'dddddddd-0000-0000-0000-000000000001';
|
||||||
|
const STATUS = {
|
||||||
|
id: 'eeeeeeee-0000-0000-0000-000000000001',
|
||||||
|
animal_id: ANIMAL_ID,
|
||||||
|
date: new Date('2024-06-01').toISOString(),
|
||||||
|
experiment_description: 'Baseline measurement',
|
||||||
|
vitals: 'HR 72, Temp 37.2',
|
||||||
|
treatment: 'Control',
|
||||||
|
notes: 'Healthy',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
describe('GET /api/daily-statuses', () => {
|
||||||
|
it('returns statuses filtered by animalId', async () => {
|
||||||
|
prisma.dailyStatus.findMany.mockResolvedValue([STATUS]);
|
||||||
|
const res = await request(app).get(`/api/daily-statuses?animalId=${ANIMAL_ID}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0].vitals).toBe('HR 72, Temp 37.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 for invalid animalId UUID', async () => {
|
||||||
|
const res = await request(app).get('/api/daily-statuses?animalId=invalid');
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/daily-statuses/:id', () => {
|
||||||
|
it('returns a single status entry', async () => {
|
||||||
|
prisma.dailyStatus.findUnique.mockResolvedValue({ ...STATUS, animal: {} });
|
||||||
|
const res = await request(app).get(`/api/daily-statuses/${STATUS.id}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.id).toBe(STATUS.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown id', async () => {
|
||||||
|
prisma.dailyStatus.findUnique.mockResolvedValue(null);
|
||||||
|
const res = await request(app).get('/api/daily-statuses/00000000-0000-0000-0000-000000000000');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/daily-statuses', () => {
|
||||||
|
it('creates status and returns 201 with UUID', async () => {
|
||||||
|
prisma.dailyStatus.create.mockResolvedValue(STATUS);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/daily-statuses')
|
||||||
|
.send({ animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when animal_id is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/daily-statuses')
|
||||||
|
.send({ date: '2024-06-01' });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.details).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ field: 'animal_id' })])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when date is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/daily-statuses')
|
||||||
|
.send({ animal_id: ANIMAL_ID });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.details).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ field: 'date' })])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when date is not a valid ISO date', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/daily-statuses')
|
||||||
|
.send({ animal_id: ANIMAL_ID, date: 'not-a-date' });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/daily-statuses/:id', () => {
|
||||||
|
it('updates status and writes UPDATE audit log', async () => {
|
||||||
|
const updated = { ...STATUS, vitals: 'HR 80' };
|
||||||
|
prisma.dailyStatus.findUnique
|
||||||
|
.mockResolvedValueOnce(STATUS)
|
||||||
|
.mockResolvedValueOnce(updated);
|
||||||
|
prisma.dailyStatus.update.mockResolvedValue(updated);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/daily-statuses/${STATUS.id}`)
|
||||||
|
.send({ vitals: 'HR 80' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.vitals).toBe('HR 80');
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when date format is invalid', async () => {
|
||||||
|
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/daily-statuses/${STATUS.id}`)
|
||||||
|
.send({ date: 'garbage' });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/daily-statuses/:id', () => {
|
||||||
|
it('deletes status and writes DELETE audit log', async () => {
|
||||||
|
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
|
||||||
|
prisma.dailyStatus.delete.mockResolvedValue(STATUS);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app).delete(`/api/daily-statuses/${STATUS.id}`);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
// Mock Prisma before importing app so the app uses the mock
|
||||||
|
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
|
||||||
|
const prisma = require('../src/lib/prisma');
|
||||||
|
const app = require('../src/app');
|
||||||
|
|
||||||
|
const EXPERIMENT = {
|
||||||
|
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||||
|
title: 'Test Study',
|
||||||
|
created_at: new Date('2024-01-01').toISOString(),
|
||||||
|
_count: { animals: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/experiments', () => {
|
||||||
|
it('returns list of experiments', async () => {
|
||||||
|
prisma.experiment.findMany.mockResolvedValue([EXPERIMENT]);
|
||||||
|
const res = await request(app).get('/api/experiments');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
expect(res.body[0].title).toBe('Test Study');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no experiments', async () => {
|
||||||
|
prisma.experiment.findMany.mockResolvedValue([]);
|
||||||
|
const res = await request(app).get('/api/experiments');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/experiments/:id', () => {
|
||||||
|
it('returns single experiment', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue({ ...EXPERIMENT, animals: [] });
|
||||||
|
const res = await request(app).get(`/api/experiments/${EXPERIMENT.id}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.id).toBe(EXPERIMENT.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for unknown id', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(null);
|
||||||
|
const res = await request(app).get('/api/experiments/00000000-0000-0000-0000-000000000000');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/experiments', () => {
|
||||||
|
it('creates experiment and returns 201 with UUID', async () => {
|
||||||
|
prisma.experiment.create.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/experiments')
|
||||||
|
.send({ title: 'Test Study' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.id).toBe(EXPERIMENT.id);
|
||||||
|
expect(res.body.title).toBe('Test Study');
|
||||||
|
// UUID format assertion
|
||||||
|
expect(res.body.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
);
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATE' }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when title is missing', async () => {
|
||||||
|
const res = await request(app).post('/api/experiments').send({});
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.details).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ field: 'title' })])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 when title exceeds 255 chars', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/experiments')
|
||||||
|
.send({ title: 'x'.repeat(256) });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/experiments/:id', () => {
|
||||||
|
it('updates experiment and writes audit log', async () => {
|
||||||
|
const updated = { ...EXPERIMENT, title: 'Renamed Study' };
|
||||||
|
prisma.experiment.findUnique
|
||||||
|
.mockResolvedValueOnce(EXPERIMENT) // audit middleware fetches before-state
|
||||||
|
.mockResolvedValueOnce(updated); // audit middleware fetches after-state
|
||||||
|
prisma.experiment.update.mockResolvedValue(updated);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/experiments/${EXPERIMENT.id}`)
|
||||||
|
.send({ title: 'Renamed Study' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.title).toBe('Renamed Study');
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 422 with empty title', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/experiments/${EXPERIMENT.id}`)
|
||||||
|
.send({ title: '' });
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/experiments/:id', () => {
|
||||||
|
it('deletes experiment and writes DELETE audit log', async () => {
|
||||||
|
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.experiment.delete.mockResolvedValue(EXPERIMENT);
|
||||||
|
prisma.auditLog.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await request(app).delete(`/api/experiments/${EXPERIMENT.id}`);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /health', () => {
|
||||||
|
it('returns ok status', async () => {
|
||||||
|
const res = await request(app).get('/health');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Global setup — runs once before all test suites.
|
||||||
|
// When DATABASE_URL points to a real test DB, Prisma migrations are applied here.
|
||||||
|
// In CI/local runs without a live DB, tests use mocked Prisma via jest.mock.
|
||||||
|
module.exports = async function () {
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = async function () {
|
||||||
|
// Graceful shutdown of any open handles
|
||||||
|
};
|
||||||
+10
-5
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||||
|
['@babel/preset-react', { runtime: 'automatic' }],
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E tests run against the full Docker stack (frontend on port 52867,
|
||||||
|
* backend on port 3001). To run locally:
|
||||||
|
* docker-compose up -d
|
||||||
|
* npx playwright test
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:52867';
|
||||||
|
|
||||||
|
test.describe('UI Flow — create experiment → add animal → log status', () => {
|
||||||
|
test('full happy path', async ({ page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
|
// ── Create experiment ────────────────────────────────────────
|
||||||
|
await page.click('button:has-text("New Experiment")');
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/experiment title/i).fill('E2E Test Study');
|
||||||
|
await page.click('button:has-text("Create Experiment")');
|
||||||
|
|
||||||
|
// Modal closes and new experiment appears in the list
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('E2E Test Study')).toBeVisible();
|
||||||
|
|
||||||
|
// ── Navigate into experiment ──────────────────────────────────
|
||||||
|
await page.click('text=E2E Test Study');
|
||||||
|
await expect(page.url()).toContain('/experiments/');
|
||||||
|
|
||||||
|
// ── Add animal ────────────────────────────────────────────────
|
||||||
|
await page.click('button:has-text("Add Animal")');
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/animal id/i).fill('M-E2E-001');
|
||||||
|
await page.getByLabel(/animal name/i).fill('TestMouse');
|
||||||
|
await page.click('button:has-text("Add Animal")');
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('TestMouse')).toBeVisible();
|
||||||
|
|
||||||
|
// ── Navigate into animal ──────────────────────────────────────
|
||||||
|
await page.click('text=TestMouse');
|
||||||
|
await expect(page.url()).toContain('/animals/');
|
||||||
|
|
||||||
|
// ── Log daily status ──────────────────────────────────────────
|
||||||
|
await page.click('button:has-text("Log Daily Status")');
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Date is pre-filled with today; add vitals
|
||||||
|
await page.getByLabel(/vitals/i).fill('HR 70, Temp 37.0');
|
||||||
|
await page.getByLabel(/treatment/i).fill('Control');
|
||||||
|
await page.click('button:has-text("Log Status")');
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('HR 70, Temp 37.0')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('UX Flow — validation error states', () => {
|
||||||
|
test('empty experiment title shows error', async ({ page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
await page.click('button:has-text("New Experiment")');
|
||||||
|
await page.click('button:has-text("Create Experiment")');
|
||||||
|
await expect(page.getByRole('alert')).toContainText(/title is required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty animal fields show errors', async ({ page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
|
// Need an experiment to navigate into — create one first
|
||||||
|
await page.click('button:has-text("New Experiment")');
|
||||||
|
await page.getByLabel(/experiment title/i).fill('UX Test Study');
|
||||||
|
await page.click('button:has-text("Create Experiment")');
|
||||||
|
await page.click('text=UX Test Study');
|
||||||
|
|
||||||
|
await page.click('button:has-text("Add Animal")');
|
||||||
|
await page.click('button:has-text("Add Animal")'); // submit without filling
|
||||||
|
const alerts = page.getByRole('alert');
|
||||||
|
await expect(alerts).toHaveCount(2); // ID + Name errors
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing date on daily status shows error', async ({ page }) => {
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
|
||||||
|
// Create experiment + animal to get to daily status form
|
||||||
|
await page.click('button:has-text("New Experiment")');
|
||||||
|
await page.getByLabel(/experiment title/i).fill('Date Test Study');
|
||||||
|
await page.click('button:has-text("Create Experiment")');
|
||||||
|
await page.click('text=Date Test Study');
|
||||||
|
|
||||||
|
await page.click('button:has-text("Add Animal")');
|
||||||
|
await page.getByLabel(/animal id/i).fill('M-DT-001');
|
||||||
|
await page.getByLabel(/animal name/i).fill('DateTestMouse');
|
||||||
|
await page.click('button:has-text("Add Animal")');
|
||||||
|
await page.click('text=DateTestMouse');
|
||||||
|
|
||||||
|
await page.click('button:has-text("Log Daily Status")');
|
||||||
|
// Clear the date field
|
||||||
|
await page.getByLabel(/date/i).fill('');
|
||||||
|
await page.click('button:has-text("Log Status")');
|
||||||
|
await expect(page.getByRole('alert')).toContainText(/date is required/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Animal Experiments Database</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+13996
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "experiments-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "jest --runInBand --forceExit",
|
||||||
|
"test:coverage": "jest --runInBand --forceExit --coverage",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.24.7",
|
||||||
|
"@babel/preset-env": "^7.24.7",
|
||||||
|
"@babel/preset-react": "^7.24.6",
|
||||||
|
"@playwright/test": "^1.44.1",
|
||||||
|
"@testing-library/jest-dom": "^6.4.6",
|
||||||
|
"@testing-library/react": "^14.3.1",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"babel-jest": "^29.7.0",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"postcss": "^8.4.39",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"vite": "^4.5.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"testEnvironment": "jsdom",
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
|
||||||
|
"testMatch": ["**/tests/**/*.test.{js,jsx}"],
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.[jt]sx?$": "babel-jest"
|
||||||
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30000,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL || 'http://localhost:52867',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import ExperimentDetail from './pages/ExperimentDetail';
|
||||||
|
import AnimalDetail from './pages/AnimalDetail';
|
||||||
|
|
||||||
|
function Navbar() {
|
||||||
|
return (
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 hover:text-blue-700 transition-colors">
|
||||||
|
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
Animal Experiments DB
|
||||||
|
</Link>
|
||||||
|
<nav className="text-sm text-gray-500">
|
||||||
|
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-20 text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-300 mb-4">404</h1>
|
||||||
|
<p className="text-gray-500 mb-6">Page not found.</p>
|
||||||
|
<Link to="/" className="text-blue-600 hover:underline">← Return to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navbar />
|
||||||
|
<main>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/experiments/:id" element={<ExperimentDetail />} />
|
||||||
|
<Route path="/animals/:id" element={<AnimalDetail />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor — normalize errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
const message =
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.message ||
|
||||||
|
'An unexpected error occurred';
|
||||||
|
const details = err.response?.data?.details || null;
|
||||||
|
return Promise.reject({ message, details, status: err.response?.status });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
|
// ── Experiments ────────────────────────────────────────────────────────────────
|
||||||
|
export const experimentsApi = {
|
||||||
|
list: () => api.get('/experiments').then((r) => r.data),
|
||||||
|
get: (id) => api.get(`/experiments/${id}`).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),
|
||||||
|
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 ───────────────────────────────────────────────────────────────────
|
||||||
|
export const animalsApi = {
|
||||||
|
list: (experimentId) =>
|
||||||
|
api.get('/animals', { params: { experimentId } }).then((r) => r.data),
|
||||||
|
get: (id) => api.get(`/animals/${id}`).then((r) => r.data),
|
||||||
|
create: (data) => api.post('/animals', data).then((r) => r.data),
|
||||||
|
update: (id, data) => api.put(`/animals/${id}`, data).then((r) => r.data),
|
||||||
|
delete: (id) => api.delete(`/animals/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Daily Statuses ─────────────────────────────────────────────────────────────
|
||||||
|
export const dailyStatusesApi = {
|
||||||
|
list: (animalId) =>
|
||||||
|
api.get('/daily-statuses', { params: { animalId } }).then((r) => r.data),
|
||||||
|
get: (id) => api.get(`/daily-statuses/${id}`).then((r) => r.data),
|
||||||
|
create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
|
||||||
|
update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data),
|
||||||
|
delete: (id) => api.delete(`/daily-statuses/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Audit Logs ────────────────────────────────────────────────────────────────
|
||||||
|
export const auditLogsApi = {
|
||||||
|
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import FormField, { Input } from './ui/FormField';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { animalsApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function AnimalForm({ existing, experimentId, onSuccess, onCancel }) {
|
||||||
|
const [fields, setFields] = useState({
|
||||||
|
animal_id_string: existing?.animal_id_string ?? '',
|
||||||
|
animal_name: existing?.animal_name ?? '',
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFields({
|
||||||
|
animal_id_string: existing?.animal_id_string ?? '',
|
||||||
|
animal_name: existing?.animal_name ?? '',
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
setApiError(null);
|
||||||
|
}, [existing]);
|
||||||
|
|
||||||
|
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
if (!fields.animal_id_string.trim()) e.animal_id_string = 'Animal ID is required';
|
||||||
|
if (!fields.animal_name.trim()) e.animal_name = 'Animal name is required';
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
animal_id_string: fields.animal_id_string.trim(),
|
||||||
|
animal_name: fields.animal_name.trim(),
|
||||||
|
};
|
||||||
|
const result = existing
|
||||||
|
? await animalsApi.update(existing.id, payload)
|
||||||
|
: await animalsApi.create({ ...payload, experiment_id: experimentId });
|
||||||
|
onSuccess(result);
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
{apiError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={apiError.message}
|
||||||
|
details={apiError.details}
|
||||||
|
onDismiss={() => setApiError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField label="Animal ID" name="animal_id_string" error={errors.animal_id_string} required>
|
||||||
|
<Input
|
||||||
|
name="animal_id_string"
|
||||||
|
value={fields.animal_id_string}
|
||||||
|
onChange={set('animal_id_string')}
|
||||||
|
placeholder="e.g. M-001"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Animal Name" name="animal_name" error={errors.animal_name} required>
|
||||||
|
<Input
|
||||||
|
name="animal_name"
|
||||||
|
value={fields.animal_name}
|
||||||
|
onChange={set('animal_name')}
|
||||||
|
placeholder="e.g. Whiskers"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={loading}>
|
||||||
|
{existing ? 'Save Changes' : 'Add Animal'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { auditLogsApi } from '../api/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
function DiffView({ changes }) {
|
||||||
|
const { before, after } = changes || {};
|
||||||
|
if (!before && after) {
|
||||||
|
return <span className="text-green-700 text-xs">Created: {JSON.stringify(after, null, 2)}</span>;
|
||||||
|
}
|
||||||
|
if (before && !after) {
|
||||||
|
return <span className="text-red-700 text-xs">Deleted record</span>;
|
||||||
|
}
|
||||||
|
if (before && after) {
|
||||||
|
const keys = Object.keys({ ...before, ...after }).filter(
|
||||||
|
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k])
|
||||||
|
);
|
||||||
|
if (keys.length === 0) return <span className="text-gray-400 text-xs">No field changes detected</span>;
|
||||||
|
return (
|
||||||
|
<dl className="text-xs space-y-1">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<div key={k} className="grid grid-cols-3 gap-2">
|
||||||
|
<dt className="font-medium text-gray-600">{k}</dt>
|
||||||
|
<dd className="text-red-600 line-through col-span-1 truncate">{String(before[k] ?? '')}</dd>
|
||||||
|
<dd className="text-green-700 col-span-1 truncate">{String(after[k] ?? '')}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_BADGE = {
|
||||||
|
CREATE: 'bg-green-100 text-green-800',
|
||||||
|
UPDATE: 'bg-yellow-100 text-yellow-800',
|
||||||
|
DELETE: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuditLogSection({ tableName, recordId }) {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
auditLogsApi
|
||||||
|
.list({ tableName, recordId, limit: 50 })
|
||||||
|
.then(({ logs, total }) => {
|
||||||
|
setLogs(logs);
|
||||||
|
setTotal(total);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [tableName, recordId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Modification History" className="mt-8">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Modification History
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="text-xs font-normal text-gray-500">({total} entries)</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-gray-500">Loading history…</p>}
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{!loading && !error && logs.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 italic">No modification history yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div key={log.id} className="bg-white">
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
|
||||||
|
aria-expanded={expanded === log.id}
|
||||||
|
>
|
||||||
|
<span className={`shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${ACTION_BADGE[log.action] || 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">
|
||||||
|
{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 truncate flex-1">
|
||||||
|
{log.table_name} / {log.record_id}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${expanded === log.id ? 'rotate-180' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{expanded === log.id && (
|
||||||
|
<div className="px-4 pb-3 bg-gray-50 border-t border-gray-100">
|
||||||
|
<DiffView changes={log.changes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import FormField, { Input, Textarea } from './ui/FormField';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { dailyStatusesApi } from '../api/client';
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Active fields from template (date is always first and handled separately)
|
||||||
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
|
||||||
|
function buildInitialValues() {
|
||||||
|
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 [apiError, setApiError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValues(buildInitialValues());
|
||||||
|
setErrors({});
|
||||||
|
setApiError(null);
|
||||||
|
}, [existing, template]);
|
||||||
|
|
||||||
|
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
if (!values.date) {
|
||||||
|
e.date = 'Date is required';
|
||||||
|
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(values.date) || isNaN(new Date(values.date).getTime())) {
|
||||||
|
e.date = 'Date must be a valid YYYY-MM-DD date';
|
||||||
|
}
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
// Separate builtin values from custom_fields
|
||||||
|
const builtin = {};
|
||||||
|
const custom = {};
|
||||||
|
for (const f of activeFields) {
|
||||||
|
const val = values[f.key] || 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
|
||||||
|
? await dailyStatusesApi.update(existing.id, payload)
|
||||||
|
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
|
||||||
|
|
||||||
|
onSuccess(result);
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
{apiError && (
|
||||||
|
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date — always present */}
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
||||||
|
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
import FormField, { Input } from './ui/FormField';
|
||||||
|
import Alert from './ui/Alert';
|
||||||
|
import { experimentsApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function ExperimentForm({ existing, onSuccess, onCancel }) {
|
||||||
|
const [title, setTitle] = useState(existing?.title ?? '');
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(existing?.title ?? '');
|
||||||
|
setErrors({});
|
||||||
|
setApiError(null);
|
||||||
|
}, [existing]);
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const e = {};
|
||||||
|
if (!title.trim()) e.title = 'Title is required';
|
||||||
|
else if (title.trim().length > 255) e.title = 'Title must be 255 characters or fewer';
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
try {
|
||||||
|
const result = existing
|
||||||
|
? await experimentsApi.update(existing.id, { title: title.trim() })
|
||||||
|
: await experimentsApi.create({ title: title.trim() });
|
||||||
|
onSuccess(result);
|
||||||
|
} catch (err) {
|
||||||
|
setApiError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
|
{apiError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={apiError.message}
|
||||||
|
details={apiError.details}
|
||||||
|
onDismiss={() => setApiError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField label="Experiment Title" name="title" error={errors.title} required>
|
||||||
|
<Input
|
||||||
|
name="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Drug Efficacy Study – Q3"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={loading}>
|
||||||
|
{existing ? 'Save Changes' : 'Create Experiment'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
error: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
success: 'bg-green-50 border-green-200 text-green-800',
|
||||||
|
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||||
|
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Alert({ type = 'error', message, details, onDismiss }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return (
|
||||||
|
<div role="alert" className={`rounded-md border px-4 py-3 text-sm ${styles[type]}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{message}</p>
|
||||||
|
{details && (
|
||||||
|
<ul className="mt-1 list-disc list-inside space-y-0.5 text-xs opacity-80">
|
||||||
|
{details.map((d, i) => (
|
||||||
|
<li key={i}>{d.field ? `${d.field}: ${d.message}` : d.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button onClick={onDismiss} className="shrink-0 opacity-60 hover:opacity-100" aria-label="Dismiss">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
|
||||||
|
secondary: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300',
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
|
||||||
|
ghost: 'bg-transparent hover:bg-gray-100 text-gray-600 border-transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
onClick,
|
||||||
|
type = 'button',
|
||||||
|
className = '',
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 rounded-md border font-medium
|
||||||
|
transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${variants[variant]} ${sizes[size]} ${className}
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||||
|
<p className="text-sm text-gray-600 mb-6">{message}</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={onConfirm} loading={loading}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function FormField({ label, name, error, required, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={name} className="text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ id, name, type = 'text', value, onChange, placeholder, required, disabled, className = '', ...rest }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id || name}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({ id, name, value, onChange, placeholder, rows = 3, disabled, className = '' }) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id || name}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`
|
||||||
|
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed resize-y
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, title, children, size = 'md' }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
|
<div className={`bg-white rounded-xl shadow-2xl w-full mx-4 ${widths[size]} max-h-[90vh] flex flex-col`}>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 rounded p-1 transition-colors"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
|
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import Modal from '../components/ui/Modal';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import DailyStatusForm from '../components/DailyStatusForm';
|
||||||
|
import Alert from '../components/ui/Alert';
|
||||||
|
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() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const [animal, setAnimal] = useState(null);
|
||||||
|
const [statuses, setStatuses] = useState([]);
|
||||||
|
const [template, setTemplate] = useState(location.state?.template ?? []);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
|
|
||||||
|
const [showAddStatus, setShowAddStatus] = useState(false);
|
||||||
|
const [editStatus, setEditStatus] = useState(null);
|
||||||
|
const [deleteStatus, setDeleteStatus] = useState(null);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [animalData, statusData] = await Promise.all([
|
||||||
|
animalsApi.get(id),
|
||||||
|
dailyStatusesApi.list(id),
|
||||||
|
]);
|
||||||
|
setAnimal(animalData);
|
||||||
|
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) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
function flash(msg) {
|
||||||
|
setSuccessMsg(msg);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusSaved() {
|
||||||
|
setShowAddStatus(false);
|
||||||
|
setEditStatus(null);
|
||||||
|
load();
|
||||||
|
flash(editStatus ? 'Daily status updated.' : 'Daily status logged.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteStatus() {
|
||||||
|
setDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await dailyStatusesApi.delete(deleteStatus.id);
|
||||||
|
setDeleteStatus(null);
|
||||||
|
load();
|
||||||
|
flash('Daily status removed.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setDeleteStatus(null);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<Alert type="error" message={error} />
|
||||||
|
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}>← Back</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeFields = template.filter((f) => f.active);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="text-sm text-gray-500 mb-4">
|
||||||
|
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{animal.animal_name}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
ID: <span className="font-mono">{animal.animal_id_string}</span>
|
||||||
|
{' · '}
|
||||||
|
{statuses.length} daily status {statuses.length !== 1 ? 'entries' : 'entry'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowAddStatus(true)}>+ Log Daily Status</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMsg && <Alert type="success" message={successMsg} />}
|
||||||
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||||
|
|
||||||
|
{statuses.length === 0 ? (
|
||||||
|
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
|
||||||
|
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
|
||||||
|
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{format(new Date(s.date), 'MMMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
|
||||||
|
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
|
||||||
|
</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">
|
||||||
|
{[
|
||||||
|
['Experiment Description', s.experiment_description],
|
||||||
|
['Vitals', s.vitals],
|
||||||
|
['Treatment', s.treatment],
|
||||||
|
['Notes', s.notes],
|
||||||
|
].filter(([, v]) => v).map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeFields.length > 0 &&
|
||||||
|
activeFields.every((f) => !getFieldValue(s, f)) && (
|
||||||
|
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AuditLogSection tableName="daily_statuses" />
|
||||||
|
|
||||||
|
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
|
||||||
|
<DailyStatusForm
|
||||||
|
animalId={id}
|
||||||
|
template={template}
|
||||||
|
onSuccess={handleStatusSaved}
|
||||||
|
onCancel={() => setShowAddStatus(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
|
||||||
|
<DailyStatusForm
|
||||||
|
existing={editStatus}
|
||||||
|
animalId={id}
|
||||||
|
template={template}
|
||||||
|
onSuccess={handleStatusSaved}
|
||||||
|
onCancel={() => setEditStatus(null)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleteStatus}
|
||||||
|
onClose={() => setDeleteStatus(null)}
|
||||||
|
onConfirm={handleDeleteStatus}
|
||||||
|
loading={deleteLoading}
|
||||||
|
title="Delete Daily Status"
|
||||||
|
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { experimentsApi } from '../api/client';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import Modal from '../components/ui/Modal';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import ExperimentForm from '../components/ExperimentForm';
|
||||||
|
import Alert from '../components/ui/Alert';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [experiments, setExperiments] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
|
|
||||||
|
async function loadExperiments() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await experimentsApi.list();
|
||||||
|
setExperiments(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadExperiments(); }, []);
|
||||||
|
|
||||||
|
function flash(msg) {
|
||||||
|
setSuccessMsg(msg);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreated(exp) {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
loadExperiments();
|
||||||
|
flash(`Experiment "${exp.title}" created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdated(exp) {
|
||||||
|
setEditTarget(null);
|
||||||
|
loadExperiments();
|
||||||
|
flash(`Experiment "${exp.title}" updated.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await experimentsApi.delete(deleteTarget.id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
loadExperiments();
|
||||||
|
flash('Experiment deleted.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Experiments</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Manage all animal experimental studies</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreateModal(true)}>
|
||||||
|
+ New Experiment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMsg && <Alert type="success" message={successMsg} className="mb-4" />}
|
||||||
|
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-16 text-gray-400" aria-live="polite" aria-busy="true">Loading experiments…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && experiments.length === 0 && (
|
||||||
|
<div className="text-center py-20 border-2 border-dashed border-gray-200 rounded-xl">
|
||||||
|
<p className="text-gray-400 text-lg mb-2">No experiments yet</p>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">Create your first experiment to get started.</p>
|
||||||
|
<Button onClick={() => setShowCreateModal(true)}>+ New Experiment</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && experiments.length > 0 && (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{experiments.map((exp) => (
|
||||||
|
<div
|
||||||
|
key={exp.id}
|
||||||
|
className="bg-white border border-gray-200 rounded-xl p-5 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
|
||||||
|
onClick={() => navigate(`/experiments/${exp.id}`)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||||
|
{exp.title}
|
||||||
|
</h2>
|
||||||
|
<div className="text-sm text-gray-500 mt-0.5 flex gap-4">
|
||||||
|
<span>{exp._count?.animals ?? 0} animal{exp._count?.animals !== 1 ? 's' : ''}</span>
|
||||||
|
<span>Created {format(new Date(exp.created_at), 'MMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setEditTarget(exp)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(exp)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AuditLogSection tableName="experiments" />
|
||||||
|
|
||||||
|
{/* Create modal */}
|
||||||
|
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="New Experiment">
|
||||||
|
<ExperimentForm onSuccess={handleCreated} onCancel={() => setShowCreateModal(false)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
<Modal isOpen={!!editTarget} onClose={() => setEditTarget(null)} title="Edit Experiment">
|
||||||
|
<ExperimentForm
|
||||||
|
existing={editTarget}
|
||||||
|
onSuccess={handleUpdated}
|
||||||
|
onCancel={() => setEditTarget(null)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
loading={deleteLoading}
|
||||||
|
title="Delete Experiment"
|
||||||
|
message={`Are you sure you want to delete "${deleteTarget?.title}"? All associated animals and daily statuses will be permanently removed.`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { experimentsApi, animalsApi } from '../api/client';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import Modal from '../components/ui/Modal';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import AnimalForm from '../components/AnimalForm';
|
||||||
|
import TemplateEditor from '../components/TemplateEditor';
|
||||||
|
import Alert from '../components/ui/Alert';
|
||||||
|
import AuditLogSection from '../components/AuditLogSection';
|
||||||
|
|
||||||
|
export default function ExperimentDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [experiment, setExperiment] = useState(null);
|
||||||
|
const [animals, setAnimals] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState(null);
|
||||||
|
|
||||||
|
const [showAddAnimal, setShowAddAnimal] = useState(false);
|
||||||
|
const [editAnimal, setEditAnimal] = useState(null);
|
||||||
|
const [deleteAnimal, setDeleteAnimal] = useState(null);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [showTemplate, setShowTemplate] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [exp, anims] = await Promise.all([
|
||||||
|
experimentsApi.get(id),
|
||||||
|
animalsApi.list(id),
|
||||||
|
]);
|
||||||
|
setExperiment(exp);
|
||||||
|
setAnimals(anims);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
function flash(msg) {
|
||||||
|
setSuccessMsg(msg);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAnimalSaved(animal) {
|
||||||
|
setShowAddAnimal(false);
|
||||||
|
setEditAnimal(null);
|
||||||
|
load();
|
||||||
|
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAnimal() {
|
||||||
|
setDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
await animalsApi.delete(deleteAnimal.id);
|
||||||
|
setDeleteAnimal(null);
|
||||||
|
load();
|
||||||
|
flash('Animal removed.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setDeleteAnimal(null);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<Alert type="error" message={error} />
|
||||||
|
<Button variant="secondary" className="mt-4" onClick={() => navigate('/')}>← Back</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="text-sm text-gray-500 mb-4">
|
||||||
|
<Link to="/" className="hover:text-blue-600">Experiments</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span className="text-gray-900 font-medium">{experiment.title}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{experiment.title}</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMsg && <Alert type="success" message={successMsg} />}
|
||||||
|
{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 ? (
|
||||||
|
<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>
|
||||||
|
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{animals.map((animal) => (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
|
||||||
|
{animal.animal_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
|
||||||
|
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
|
||||||
|
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleteAnimal}
|
||||||
|
onClose={() => setDeleteAnimal(null)}
|
||||||
|
onConfirm={handleDeleteAnimal}
|
||||||
|
loading={deleteLoading}
|
||||||
|
title="Remove Animal"
|
||||||
|
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import AnimalForm from '../src/components/AnimalForm';
|
||||||
|
import * as client from '../src/api/client';
|
||||||
|
|
||||||
|
jest.mock('../src/api/client', () => ({
|
||||||
|
animalsApi: {
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const EXP_ID = 'exp-001';
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
const onCancel = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
describe('AnimalForm', () => {
|
||||||
|
it('renders Animal ID and Animal Name fields', () => {
|
||||||
|
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
expect(screen.getByLabelText(/animal id/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/animal name/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows required field errors on empty submit', async () => {
|
||||||
|
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
|
||||||
|
expect(await screen.findAllByRole('alert')).toHaveLength(2);
|
||||||
|
expect(client.animalsApi.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls create with correct payload', async () => {
|
||||||
|
const animal = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers', experiment_id: EXP_ID };
|
||||||
|
client.animalsApi.create.mockResolvedValue(animal);
|
||||||
|
|
||||||
|
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/animal id/i), 'M-001');
|
||||||
|
await userEvent.type(screen.getByLabelText(/animal name/i), 'Whiskers');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.animalsApi.create).toHaveBeenCalledWith({
|
||||||
|
animal_id_string: 'M-001',
|
||||||
|
animal_name: 'Whiskers',
|
||||||
|
experiment_id: EXP_ID,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(animal));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills fields for existing animal and uses update API', async () => {
|
||||||
|
const existing = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers' };
|
||||||
|
const updated = { ...existing, animal_name: 'Mr Whiskers' };
|
||||||
|
client.animalsApi.update.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
render(<AnimalForm existing={existing} experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
expect(screen.getByDisplayValue('Whiskers')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const nameInput = screen.getByLabelText(/animal name/i);
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, 'Mr Whiskers');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.animalsApi.update).toHaveBeenCalledWith('a-1', {
|
||||||
|
animal_id_string: 'M-001',
|
||||||
|
animal_name: 'Mr Whiskers',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import Button from '../src/components/ui/Button';
|
||||||
|
|
||||||
|
describe('Button component', () => {
|
||||||
|
it('renders with children text', () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when clicked', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
render(<Button onClick={onClick}>Click</Button>);
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled when disabled prop is true', () => {
|
||||||
|
render(<Button disabled>Submit</Button>);
|
||||||
|
expect(screen.getByRole('button')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled and shows spinner when loading', () => {
|
||||||
|
render(<Button loading>Submit</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
expect(btn.querySelector('svg')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders danger variant', () => {
|
||||||
|
render(<Button variant="danger">Delete</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.className).toContain('bg-red-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onClick when disabled', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
render(<Button disabled onClick={onClick}>Click</Button>);
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import DailyStatusForm from '../src/components/DailyStatusForm';
|
||||||
|
import * as client from '../src/api/client';
|
||||||
|
|
||||||
|
jest.mock('../src/api/client', () => ({
|
||||||
|
dailyStatusesApi: {
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ANIMAL_ID = 'animal-001';
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
const onCancel = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
describe('DailyStatusForm', () => {
|
||||||
|
it('renders date field (required) and optional fields', () => {
|
||||||
|
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/experiment description/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills date with today by default', () => {
|
||||||
|
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when date is cleared', async () => {
|
||||||
|
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
const dateInput = screen.getByLabelText(/date/i);
|
||||||
|
await userEvent.clear(dateInput);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/date is required/i);
|
||||||
|
expect(client.dailyStatusesApi.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls create with correct payload including animal_id', async () => {
|
||||||
|
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
|
||||||
|
client.dailyStatusesApi.create.mockResolvedValue(status);
|
||||||
|
|
||||||
|
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/date/i);
|
||||||
|
await userEvent.clear(dateInput);
|
||||||
|
await userEvent.type(dateInput, '2024-06-01');
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.dailyStatusesApi.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ animal_id: ANIMAL_ID, vitals: 'HR 72' })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Save Changes" and pre-fills fields for existing status', () => {
|
||||||
|
const existing = {
|
||||||
|
id: 's-1',
|
||||||
|
date: '2024-06-01T00:00:00.000Z',
|
||||||
|
vitals: 'HR 72',
|
||||||
|
treatment: 'Control',
|
||||||
|
notes: 'Normal',
|
||||||
|
experiment_description: 'Baseline',
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DailyStatusForm
|
||||||
|
existing={existing}
|
||||||
|
animalId={ANIMAL_ID}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('HR 72')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCancel when Cancel is clicked', () => {
|
||||||
|
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import ExperimentForm from '../src/components/ExperimentForm';
|
||||||
|
import * as client from '../src/api/client';
|
||||||
|
|
||||||
|
jest.mock('../src/api/client', () => ({
|
||||||
|
experimentsApi: {
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
const onCancel = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExperimentForm', () => {
|
||||||
|
it('renders title input and submit button', () => {
|
||||||
|
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
expect(screen.getByLabelText(/experiment title/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /create experiment/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows validation error when submitting empty title', async () => {
|
||||||
|
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/title is required/i);
|
||||||
|
expect(client.experimentsApi.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when title exceeds 255 chars', async () => {
|
||||||
|
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/experiment title/i), 'x'.repeat(256));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/255/i);
|
||||||
|
expect(client.experimentsApi.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls create API with trimmed title and calls onSuccess', async () => {
|
||||||
|
const experiment = { id: 'abc-123', title: 'New Study' };
|
||||||
|
client.experimentsApi.create.mockResolvedValue(experiment);
|
||||||
|
|
||||||
|
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/experiment title/i), 'New Study');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(client.experimentsApi.create).toHaveBeenCalledWith({ title: 'New Study' }));
|
||||||
|
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(experiment));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Save Changes" button for existing experiment', () => {
|
||||||
|
render(
|
||||||
|
<ExperimentForm
|
||||||
|
existing={{ id: 'abc', title: 'Old Title' }}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Old Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls update API for existing experiment', async () => {
|
||||||
|
const updated = { id: 'abc', title: 'Updated Title' };
|
||||||
|
client.experimentsApi.update.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ExperimentForm
|
||||||
|
existing={{ id: 'abc', title: 'Old Title' }}
|
||||||
|
onSuccess={onSuccess}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('Old Title');
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, 'Updated Title');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.experimentsApi.update).toHaveBeenCalledWith('abc', { title: 'Updated Title' })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCancel when Cancel is clicked', () => {
|
||||||
|
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows API error when create fails', async () => {
|
||||||
|
client.experimentsApi.create.mockRejectedValue({ message: 'Server error', details: null });
|
||||||
|
|
||||||
|
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/experiment title/i), 'Test');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/server error/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import Modal from '../src/components/ui/Modal';
|
||||||
|
|
||||||
|
describe('Modal component', () => {
|
||||||
|
it('does not render when isOpen is false', () => {
|
||||||
|
render(<Modal isOpen={false} onClose={() => {}} title="Test"><p>content</p></Modal>);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title and children when open', () => {
|
||||||
|
render(<Modal isOpen={true} onClose={() => {}} title="My Modal"><p>Hello</p></Modal>);
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('My Modal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when close button is clicked', () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
|
||||||
|
fireEvent.click(screen.getByLabelText(/close modal/i));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when Escape key is pressed', () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./tests/setup.js'],
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user