Login sequence and inventory/protocol storage groundwork
This commit is contained in:
37
server/src/auth/auth.ts
Normal file
37
server/src/auth/auth.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { kyselyAdapter } from '@better-auth/kysely-adapter';
|
||||
import { Kysely, PostgresDialect } from 'kysely';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const db = new Kysely({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new Pool({
|
||||
connectionString: process.env.DATABASE_URL ||
|
||||
'postgresql://labwise:labwise_dev_pw@localhost:5432/labwise_db',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: kyselyAdapter(db, { type: 'postgres' }),
|
||||
|
||||
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001',
|
||||
secret: process.env.BETTER_AUTH_SECRET || 'dev-secret-change-in-production-min32chars!!',
|
||||
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
},
|
||||
microsoft: {
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID || '',
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || '',
|
||||
tenantId: process.env.MICROSOFT_TENANT_ID || 'common',
|
||||
},
|
||||
},
|
||||
|
||||
trustedOrigins: [
|
||||
'http://localhost:5173',
|
||||
'https://labwise.wahwa.com',
|
||||
],
|
||||
});
|
||||
30
server/src/auth/middleware.ts
Normal file
30
server/src/auth/middleware.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { auth } from './auth';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: { id: string; email: string; name: string };
|
||||
sessionId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: fromNodeHeaders(req.headers),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
req.user = session.user;
|
||||
req.sessionId = session.session.id;
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
16
server/src/auth/rateLimiter.ts
Normal file
16
server/src/auth/rateLimiter.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
export const authRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many auth attempts, please try again later' },
|
||||
});
|
||||
|
||||
export const apiRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 200,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
15
server/src/db/migrate.ts
Normal file
15
server/src/db/migrate.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { pool } from './pool';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function migrate() {
|
||||
const sql = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8');
|
||||
await pool.query(sql);
|
||||
console.log('Migration complete');
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
migrate().catch(err => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
9
server/src/db/pool.ts
Normal file
9
server/src/db/pool.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL ||
|
||||
'postgresql://labwise:labwise_dev_pw@localhost:5432/labwise_db',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
63
server/src/db/schema.sql
Normal file
63
server/src/db/schema.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- Chemical inventory, scoped per user
|
||||
CREATE TABLE IF NOT EXISTS chemicals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
-- Required fields
|
||||
pi_first_name TEXT NOT NULL,
|
||||
physical_state TEXT NOT NULL,
|
||||
chemical_name TEXT NOT NULL,
|
||||
bldg_code TEXT NOT NULL,
|
||||
lab TEXT NOT NULL,
|
||||
storage_location TEXT NOT NULL,
|
||||
storage_device TEXT NOT NULL,
|
||||
number_of_containers TEXT NOT NULL,
|
||||
amount_per_container TEXT NOT NULL,
|
||||
unit_of_measure TEXT NOT NULL,
|
||||
cas_number TEXT NOT NULL,
|
||||
|
||||
-- Optional fields
|
||||
chemical_formula TEXT,
|
||||
molecular_weight TEXT,
|
||||
vendor TEXT,
|
||||
catalog_number TEXT,
|
||||
found_in_catalog TEXT,
|
||||
po_number TEXT,
|
||||
receipt_date TEXT,
|
||||
open_date TEXT,
|
||||
max_on_hand TEXT,
|
||||
expiration_date DATE,
|
||||
contact TEXT,
|
||||
comments TEXT,
|
||||
permit_number TEXT,
|
||||
barcode TEXT,
|
||||
concentration TEXT,
|
||||
chemical_number TEXT,
|
||||
lot_number TEXT,
|
||||
multiple_cas TEXT,
|
||||
msds TEXT,
|
||||
percentage_full NUMERIC(5,2),
|
||||
needs_manual_entry TEXT[],
|
||||
scanned_image TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS chemicals_user_id_idx ON chemicals(user_id);
|
||||
CREATE INDEX IF NOT EXISTS chemicals_cas_number_idx ON chemicals(cas_number);
|
||||
|
||||
-- Protocols with JSONB analysis results
|
||||
CREATE TABLE IF NOT EXISTS protocols (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
file_url TEXT,
|
||||
analysis_results JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS protocols_user_id_idx ON protocols(user_id);
|
||||
44
server/src/index.ts
Normal file
44
server/src/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { toNodeHandler } from 'better-auth/node';
|
||||
import { auth } from './auth/auth';
|
||||
import { authRateLimiter, apiRateLimiter } from './auth/rateLimiter';
|
||||
import chemicalsRouter from './routes/chemicals';
|
||||
import protocolsRouter from './routes/protocols';
|
||||
import path from 'path';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '../uploads');
|
||||
|
||||
// Trust Cloudflare/proxy X-Forwarded-For headers
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(cors({
|
||||
origin: [
|
||||
'http://localhost:5173',
|
||||
'https://labwise.wahwa.com',
|
||||
],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Serve uploaded files
|
||||
app.use('/uploads', express.static(UPLOADS_DIR));
|
||||
|
||||
// Better Auth — must come before express.json() so it can read its own body
|
||||
app.use('/api/auth/*', authRateLimiter);
|
||||
app.all('/api/auth/*', toNodeHandler(auth));
|
||||
|
||||
// Body parsing for all other routes
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Application routes
|
||||
app.use('/api', apiRateLimiter);
|
||||
app.use('/api/chemicals', chemicalsRouter);
|
||||
app.use('/api/protocols', protocolsRouter);
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`LabWise API running on http://localhost:${PORT}`);
|
||||
});
|
||||
106
server/src/routes/chemicals.ts
Normal file
106
server/src/routes/chemicals.ts
Normal 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;
|
||||
110
server/src/routes/protocols.ts
Normal file
110
server/src/routes/protocols.ts
Normal 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;
|
||||
Reference in New Issue
Block a user