Files
OMT-SM/server/src/index.js
T

411 lines
15 KiB
JavaScript

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import pkg from 'pg';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
const { Pool } = pkg;
const PORT = Number(process.env.PORT || 4000);
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h';
const LOCAL_DEV_TOOLS_ENABLED = process.env.ENABLE_LOCAL_TEST_ROUTES === '1'
|| (process.env.NODE_ENV !== 'production' && /localhost|127\.0\.0\.1/i.test(DATABASE_URL));
const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5173,http://localhost:8080')
.split(',').map(s => s.trim()).filter(Boolean);
function isPrivateIpv4(hostname) {
return /^10\./.test(hostname)
|| /^127\./.test(hostname)
|| /^192\.168\./.test(hostname)
|| /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
}
function isAllowedOrigin(origin) {
if (!origin) return true;
if (CORS_ORIGINS.includes(origin)) return true;
try {
const url = new URL(origin);
return ['localhost', '127.0.0.1'].includes(url.hostname) || isPrivateIpv4(url.hostname);
} catch {
return false;
}
}
const pool = new Pool({ connectionString: DATABASE_URL, max: 10 });
const app = express();
app.use(cors({
origin(origin, callback) {
callback(isAllowedOrigin(origin) ? null : new Error('Not allowed by CORS'), isAllowedOrigin(origin));
},
credentials: true,
}));
app.use(express.json({ limit: '1mb' }));
// ---- helpers ---------------------------------------------------------
function signToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: 'authenticated' },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN, audience: 'authenticated' },
);
}
function authRequired(req, res, next) {
const hdr = req.get('authorization') || '';
const m = hdr.match(/^Bearer\s+(.+)$/i);
if (!m) return res.status(401).json({ error: 'missing token' });
try {
const claims = jwt.verify(m[1], JWT_SECRET);
req.user = { id: claims.sub, email: claims.email, role: claims.role || 'authenticated', claims };
next();
} catch (e) {
return res.status(401).json({ error: 'invalid token' });
}
}
/**
* Run `fn(client)` on a checked-out connection that has its session
* configured to impersonate the authenticated user, so RLS works:
* SET LOCAL request.jwt.claim.sub = <user_id>
* SET LOCAL request.jwt.claims = <full claims as json>
* SET LOCAL ROLE authenticated
*/
async function withUserClient(req, fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SELECT set_config('request.jwt.claim.sub', $1, true)", [req.user.id]);
await client.query("SELECT set_config('request.jwt.claim.role', $1, true)", [req.user.role]);
await client.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify(req.user.claims)]);
await client.query("SET LOCAL ROLE authenticated");
const out = await fn(client);
await client.query('COMMIT');
return out;
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
}
function dbError(res, e) {
console.error('[db]', e.code || '', e.message);
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
}
// ---- auth ------------------------------------------------------------
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'email and password required' });
try {
const { rows } = await pool.query(
'SELECT id, email, password_hash, full_name, is_active FROM auth.users WHERE email = $1 LIMIT 1',
[String(email).trim().toLowerCase()],
);
const u = rows[0];
if (!u || !u.is_active) return res.status(401).json({ error: 'invalid credentials' });
const ok = await bcrypt.compare(password, u.password_hash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
await pool.query('UPDATE auth.users SET last_login_at = now() WHERE id = $1', [u.id]);
const token = signToken(u);
res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name } });
} catch (e) { dbError(res, e); }
});
app.post('/auth/logout', authRequired, (_req, res) => {
// Stateless JWT — client just drops the token. (Add a denylist if needed.)
res.json({ ok: true });
});
// ---- generic RPC -----------------------------------------------------
// POST /rpc/:fn body = { ...named args matching app.<fn>(...) signature }
app.post('/rpc/:fn', authRequired, async (req, res) => {
const fn = req.params.fn;
if (!/^[a-z_][a-z0-9_]{0,62}$/i.test(fn)) {
return res.status(400).json({ error: 'invalid function name' });
}
const args = req.body && typeof req.body === 'object' ? req.body : {};
const names = Object.keys(args);
// Always call as `SELECT * FROM app.<fn>(...)` so SETOF/TABLE/composite
// functions expand to rows/columns. Scalar functions yield one row with a
// single column named after the function.
const argList = names.map((n, i) => `${n} => $${i + 1}`).join(', ');
const sql = `SELECT * FROM app.${fn}(${argList})`;
const params = names.map((n) => args[n]);
try {
const data = await withUserClient(req, async (client) => {
const r = await client.query(sql, params);
// Scalar: 1 row, 1 column => unwrap.
if (r.rows.length === 1 && r.fields.length === 1) {
return r.rows[0][r.fields[0].name];
}
// Single-row composite (e.g. RETURNS record / OUT params): return as object.
if (r.rows.length === 1) return r.rows[0];
return r.rows;
});
res.json({ data });
} catch (e) { dbError(res, e); }
});
// ---- table/view reads ------------------------------------------------
// GET /from/:view?col=val&col2=val2 -> SELECT * FROM app.<view> WHERE ...
const ALLOWED_VIEWS = new Set([
'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions',
'v_manage_tills',
// Owner / manager dashboards (RLS still restricts rows to allowed shops):
'v_owner_dashboard', 'v_z_report', 'v_employee_scorecard_30d', 'alerts', 'v_end_of_day_reports',
]);
app.get('/from/:view', authRequired, async (req, res) => {
const view = req.params.view;
if (!ALLOWED_VIEWS.has(view)) return res.status(404).json({ error: 'unknown view' });
const filters = Object.entries(req.query).filter(([k]) => /^[a-z_][a-z0-9_]*$/i.test(k));
const where = filters.length
? 'WHERE ' + filters.map(([k], i) => `${k} = $${i + 1}`).join(' AND ')
: '';
const params = filters.map(([, v]) => v);
try {
const data = await withUserClient(req, async (client) => {
const r = await client.query(`SELECT * FROM app.${view} ${where}`, params);
return r.rows;
});
res.json({ data });
} catch (e) { dbError(res, e); }
});
// ---- employees + employee transactions (Employee Payment Report) -----
app.get('/employees', authRequired, async (_req, res) => {
try {
const { rows } = await pool.query(
'SELECT id, emp_id, name, email, department, location FROM app.employees ORDER BY name',
);
res.json({ data: rows });
} catch (e) { dbError(res, e); }
});
app.post('/employees', authRequired, async (req, res) => {
const { emp_id, name, email, department, location } = req.body || {};
if (!emp_id || !name) return res.status(400).json({ error: 'emp_id and name required' });
try {
const { rows } = await pool.query(
`INSERT INTO app.employees(emp_id, name, email, department, location)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department, location = EXCLUDED.location
RETURNING id, emp_id, name, email, department, location`,
[emp_id, name, email || null, department || null, location || null],
);
res.json({ data: rows[0] });
} catch (e) { dbError(res, e); }
});
app.get('/employee_transactions', authRequired, async (req, res) => {
const { employee_id } = req.query;
try {
const { rows } = await pool.query(
employee_id
? `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency
FROM app.employee_transactions WHERE employee_id = $1 ORDER BY transaction_date`
: `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency
FROM app.employee_transactions ORDER BY transaction_date`,
employee_id ? [employee_id] : [],
);
res.json({ data: rows });
} catch (e) { dbError(res, e); }
});
app.post('/employee_transactions', authRequired, async (req, res) => {
const { employee_id, transaction_date, collection_amount, deposit_amount, currency } = req.body || {};
if (!employee_id || !transaction_date) {
return res.status(400).json({ error: 'employee_id and transaction_date required' });
}
try {
const { rows } = await pool.query(
`INSERT INTO app.employee_transactions
(employee_id, transaction_date, collection_amount, deposit_amount, currency)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, employee_id, transaction_date, collection_amount, deposit_amount, currency`,
[
employee_id,
transaction_date,
Number(collection_amount) || 0,
Number(deposit_amount) || 0,
currency || 'USD',
],
);
res.json({ data: rows[0] });
} catch (e) { dbError(res, e); }
});
// ---- admin: user management -----------------------------------------
async function ensureAdmin(req, res) {
// Owner-anywhere == admin in the UI. Compute via app.is_owner_anywhere().
try {
const ok = await withUserClient(req, async (client) => {
const r = await client.query('SELECT app.is_owner_anywhere() AS ok');
return !!r.rows[0]?.ok;
});
if (!ok) { res.status(403).json({ error: 'admin only' }); return false; }
return true;
} catch (e) { dbError(res, e); return false; }
}
app.get('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
try {
const { rows } = await pool.query(`
SELECT u.id, u.email, u.is_active, u.full_name,
p.full_name AS profile_name,
coalesce(
(SELECT a.role::text FROM app.user_shop_assignments a
WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1),
'cashier'
) AS shop_role,
EXISTS (SELECT 1 FROM app.user_shop_assignments a
WHERE a.user_id = u.id AND a.role = 'owner') AS is_admin,
(SELECT e.emp_id FROM app.employees e WHERE e.email = u.email LIMIT 1) AS emp_id,
(SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department
FROM auth.users u
LEFT JOIN app.user_profiles p ON p.user_id = u.id
ORDER BY u.created_at
`);
res.json({ data: rows });
} catch (e) { dbError(res, e); }
});
app.post('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
const { email, password, name, role, department, empId } = req.body || {};
if (!email || !password || !name) {
return res.status(400).json({ error: 'email, password, name required' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'password must be at least 6 chars' });
}
const isAdmin = role === 'admin';
const client = await pool.connect();
try {
await client.query('BEGIN');
const hash = await bcrypt.hash(password, 10);
const u = await client.query(
`INSERT INTO auth.users(email, password_hash, full_name, is_active)
VALUES ($1, $2, $3, true) RETURNING id, email, full_name`,
[String(email).trim().toLowerCase(), hash, name],
);
const userId = u.rows[0].id;
await client.query(
`INSERT INTO app.user_profiles(user_id, full_name, is_active)
VALUES ($1, $2, true)
ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`,
[userId, name],
);
// Assign to the first available shop (Default Shop typically) so they have a role.
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1');
if (shop.rows[0]) {
await client.query(
`INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`,
[userId, shop.rows[0].id, isAdmin ? 'owner' : 'cashier', req.user.id],
);
}
if (!isAdmin && empId) {
await client.query(
`INSERT INTO app.employees(emp_id, name, email, department)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department`,
[empId, name, email, department || null],
);
}
await client.query('COMMIT');
res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } });
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
dbError(res, e);
} finally {
client.release();
}
});
app.delete('/admin/users/:id', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'cannot delete yourself' });
}
try {
await pool.query('DELETE FROM auth.users WHERE id = $1', [req.params.id]);
res.json({ ok: true });
} catch (e) { dbError(res, e); }
});
app.post('/admin/dev/end_of_day/reopen_latest', authRequired, async (req, res) => {
if (!LOCAL_DEV_TOOLS_ENABLED) {
return res.status(404).json({ error: 'not found' });
}
if (!(await ensureAdmin(req, res))) return;
const { shop_id: shopId } = req.body || {};
if (!shopId) {
return res.status(400).json({ error: 'shop_id required' });
}
try {
const r = await pool.query(
`with shop_bounds as (
select
min(business_date) as oldest_business_date,
min(submitted_at) as oldest_submitted_at
from app.end_of_day_reports
where shop_id = $1
),
latest as (
select id
from app.end_of_day_reports
where shop_id = $1
order by submitted_at desc
limit 1
)
update app.end_of_day_reports e
set business_date = shop_bounds.oldest_business_date - interval '1 day',
submitted_at = shop_bounds.oldest_submitted_at - interval '1 day'
from latest, shop_bounds
where e.id = latest.id
returning e.id, e.shop_id, e.business_date, e.submitted_at`,
[shopId],
);
const data = r.rows[0] ?? null;
if (!data) {
return res.status(404).json({ error: 'no end-of-day report found for this shop' });
}
res.json({ data });
} catch (e) { dbError(res, e); }
});
// ---- health ----------------------------------------------------------
app.get('/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true }); }
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
app.listen(PORT, () => {
console.log(`[server] listening on http://localhost:${PORT}`);
});