daf36908ef
Adds a FileDropZone at the bottom of the day view where users can drop all session files at once. Filenames are regex-matched to subjects using their animal_id_string (word-boundary aware, so "2852" won't match "28520"). Only metadata is recorded — files are never uploaded. Backend: new session_files table (id, daily_status_id, filename, file_size BIGINT, file_type, last_modified, notes). Routes: POST /api/daily-statuses/:id/files (batch create) GET /api/daily-statuses/:id/files DELETE /api/session-files/:id Frontend: FileDropZone shows per-subject match groups, unmatched files, and a warning for subjects with no match. After saving, registered files are displayed with delete capability. test-files/: generate_test_files.sh creates 4 dummy files per subject (2×bin, 2×csv) with correct naming for drag-and-drop testing. Pre-generated for 2026-04-23 with IDs 2852/3076/3077/3078. Tests: 19 tests covering drag state, regex matching, save payload, partial-overlap prevention, multi-subject batching, and delete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
76 lines
2.4 KiB
JavaScript
76 lines
2.4 KiB
JavaScript
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 analysesRouter = require('./routes/analyses');
|
|
const sessionFilesRouter = require('./routes/sessionFiles');
|
|
|
|
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() });
|
|
});
|
|
|
|
// DB info — returns database timezone (no rate limit)
|
|
const prisma = require('./lib/prisma');
|
|
app.get('/api/info', async (req, res) => {
|
|
try {
|
|
const [{ timezone }] = await prisma.$queryRaw`SELECT current_setting('TimeZone') AS timezone`;
|
|
res.json({ dbTimezone: timezone });
|
|
} catch {
|
|
res.json({ dbTimezone: null });
|
|
}
|
|
});
|
|
|
|
// 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('/api', analysesRouter);
|
|
app.use('/api', sessionFilesRouter);
|
|
|
|
app.use((req, res) => {
|
|
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
|
|
});
|
|
|
|
app.use(globalErrorHandler);
|
|
|
|
module.exports = app;
|