Login sequence and inventory/protocol storage groundwork

This commit is contained in:
2026-03-19 05:42:11 +00:00
parent 5b2c7e4506
commit 55bbd6909d
21 changed files with 3882 additions and 157 deletions

View File

@@ -0,0 +1,106 @@
import { Router } from 'express';
import { pool } from '../db/pool';
import { requireAuth } from '../auth/middleware';
const router = Router();
router.use(requireAuth);
function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
}
// GET /api/chemicals
router.get('/', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM chemicals WHERE user_id = $1 ORDER BY created_at DESC',
[req.user!.id]
);
// Map snake_case columns back to camelCase for the frontend
const rows = result.rows.map(snakeToCamel);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/chemicals
router.post('/', async (req, res) => {
try {
const b = req.body;
const result = await pool.query(`
INSERT INTO chemicals (
user_id, pi_first_name, physical_state, chemical_name, bldg_code, lab,
storage_location, storage_device, number_of_containers, amount_per_container,
unit_of_measure, cas_number,
chemical_formula, molecular_weight, vendor, catalog_number, lot_number,
expiration_date, concentration, percentage_full, needs_manual_entry,
scanned_image, comments, barcode, contact
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25
) RETURNING *`,
[
req.user!.id, b.piFirstName, b.physicalState, b.chemicalName, b.bldgCode, b.lab,
b.storageLocation, b.storageDevice, b.numberOfContainers, b.amountPerContainer,
b.unitOfMeasure, b.casNumber,
b.chemicalFormula ?? null, b.molecularWeight ?? null, b.vendor ?? null,
b.catalogNumber ?? null, b.lotNumber ?? null,
b.expirationDate ?? null, b.concentration ?? null,
b.percentageFull ?? null, b.needsManualEntry ?? null,
b.scannedImage ?? null, b.comments ?? null, b.barcode ?? null, b.contact ?? null,
]
);
res.status(201).json(snakeToCamel(result.rows[0]));
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/chemicals/:id
router.patch('/:id', async (req, res) => {
try {
const fields = Object.keys(req.body);
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
const snakeFields = fields.map(camelToSnake);
const setClauses = snakeFields.map((f, i) => `${f} = $${i + 3}`).join(', ');
const values = fields.map(f => req.body[f]);
const result = await pool.query(
`UPDATE chemicals SET ${setClauses}, updated_at = NOW()
WHERE id = $1 AND user_id = $2 RETURNING *`,
[req.params.id, req.user!.id, ...values]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(snakeToCamel(result.rows[0]));
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE /api/chemicals/:id
router.delete('/:id', async (req, res) => {
try {
await pool.query('DELETE FROM chemicals WHERE id = $1 AND user_id = $2',
[req.params.id, req.user!.id]);
res.status(204).end();
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
function snakeToCamel(row: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(row).map(([k, v]) => [
k.replace(/_([a-z])/g, (_, l) => l.toUpperCase()),
v,
])
);
}
export default router;

View File

@@ -0,0 +1,110 @@
import { Router } from 'express';
import { pool } from '../db/pool';
import { requireAuth } from '../auth/middleware';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
const router = Router();
router.use(requireAuth);
const uploadsDir = process.env.UPLOADS_DIR || path.join(__dirname, '../../uploads');
fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
const userDir = path.join(uploadsDir, (req as any).user.id);
fs.mkdirSync(userDir, { recursive: true });
cb(null, userDir);
},
filename: (_req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${unique}${path.extname(file.originalname)}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.pdf', '.doc', '.docx', '.txt'];
if (allowed.includes(path.extname(file.originalname).toLowerCase())) {
cb(null, true);
} else {
cb(new Error('Only PDF, DOC, DOCX, TXT files allowed'));
}
},
});
// GET /api/protocols
router.get('/', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM protocols WHERE user_id = $1 ORDER BY created_at DESC',
[req.user!.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/protocols
router.post('/', upload.single('file'), async (req, res) => {
try {
const { title, content } = req.body;
const userId = (req as any).user.id;
const fileUrl = req.file ? `/uploads/${userId}/${req.file.filename}` : null;
const result = await pool.query(
`INSERT INTO protocols (user_id, title, content, file_url)
VALUES ($1, $2, $3, $4) RETURNING *`,
[req.user!.id, title || 'Untitled Protocol', content || '', fileUrl]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/protocols/:id/analysis
router.patch('/:id/analysis', async (req, res) => {
try {
const { analysis_results } = req.body;
const result = await pool.query(
`UPDATE protocols SET analysis_results = $1::jsonb, updated_at = NOW()
WHERE id = $2 AND user_id = $3 RETURNING *`,
[JSON.stringify(analysis_results), req.params.id, req.user!.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE /api/protocols/:id
router.delete('/:id', async (req, res) => {
try {
const result = await pool.query(
'DELETE FROM protocols WHERE id = $1 AND user_id = $2 RETURNING file_url',
[req.params.id, req.user!.id]
);
const fileUrl = result.rows[0]?.file_url;
if (fileUrl) {
// fileUrl is like /uploads/<userId>/<filename>
const relative = fileUrl.replace(/^\/uploads\//, '');
const filePath = path.join(uploadsDir, relative);
fs.rmSync(filePath, { force: true });
}
res.status(204).end();
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;