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 = * SET LOCAL request.jwt.claims = * 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.(...) 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.(...)` 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. 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}`); });