Add cash management schema and immediate variance alerts
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Server config (copy to server/.env or set in your shell)
|
||||
PORT=4000
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
JWT_EXPIRES_IN=12h
|
||||
CORS_ORIGIN=http://localhost:5173,http://localhost:8080
|
||||
@@ -0,0 +1,9 @@
|
||||
# Server config (copy to server/.env or set in your shell)
|
||||
PORT=4000
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
JWT_EXPIRES_IN=12h
|
||||
CORS_ORIGIN=http://localhost:5173,http://localhost:8080
|
||||
|
||||
# Frontend clients on the same LAN can use the host machine's IP automatically.
|
||||
# Add fixed origins here if you want to restrict access more tightly.
|
||||
@@ -0,0 +1,63 @@
|
||||
-- =====================================================================
|
||||
-- 0000 Auth shim
|
||||
-- Provides `auth.users`, `auth.uid()`, `auth.role()`, `auth.jwt()` so
|
||||
-- the application migrations (which were written for Supabase) run
|
||||
-- unmodified. The backend sets `request.jwt.claim.sub` (and friends)
|
||||
-- per request from the verified JWT, then `set local role authenticated`.
|
||||
-- =====================================================================
|
||||
|
||||
create extension if not exists "pgcrypto";
|
||||
create extension if not exists "citext";
|
||||
|
||||
-- Supabase ships these roles; create them if missing (e.g. plain Postgres).
|
||||
do $$ begin
|
||||
if not exists (select 1 from pg_roles where rolname = 'anon') then
|
||||
create role anon nologin noinherit;
|
||||
end if;
|
||||
if not exists (select 1 from pg_roles where rolname = 'authenticated') then
|
||||
create role authenticated nologin noinherit;
|
||||
end if;
|
||||
if not exists (select 1 from pg_roles where rolname = 'service_role') then
|
||||
create role service_role nologin noinherit bypassrls;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create schema if not exists auth;
|
||||
|
||||
-- Minimal `auth.users` compatible with FKs in app migrations.
|
||||
create table if not exists auth.users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email citext unique,
|
||||
password_hash text not null,
|
||||
full_name text,
|
||||
is_active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
last_login_at timestamptz
|
||||
);
|
||||
|
||||
create or replace function auth.uid()
|
||||
returns uuid
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
|
||||
$$;
|
||||
|
||||
create or replace function auth.role()
|
||||
returns text
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select coalesce(nullif(current_setting('request.jwt.claim.role', true), ''), 'anon')
|
||||
$$;
|
||||
|
||||
create or replace function auth.jwt()
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb)
|
||||
$$;
|
||||
|
||||
grant usage on schema auth to authenticated, anon, service_role;
|
||||
grant select on auth.users to authenticated, service_role;
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run all SQL migrations from /sql/migrations/ in lexical order.
|
||||
# On plain Postgres (no pg_cron extension), wrap any bare
|
||||
# `create extension if not exists pg_cron;` line so it doesn't abort.
|
||||
set -euo pipefail
|
||||
|
||||
TMPDIR_M=/tmp/migrations
|
||||
mkdir -p "$TMPDIR_M"
|
||||
echo ">> applying app migrations from /sql/migrations"
|
||||
for f in /sql/migrations/*.sql; do
|
||||
base="$(basename "$f")"
|
||||
# Replace the bare pg_cron extension creation with a soft variant.
|
||||
sed -E "s|^create extension if not exists pg_cron;|do \$\$ begin create extension if not exists pg_cron; exception when others then raise notice 'pg_cron unavailable, skipping schedules'; end \$\$;|i" "$f" > "$TMPDIR_M/$base"
|
||||
echo ">> $base"
|
||||
psql -v ON_ERROR_STOP=1 \
|
||||
--username "$POSTGRES_USER" \
|
||||
--dbname "$POSTGRES_DB" \
|
||||
-f "$TMPDIR_M/$base"
|
||||
done
|
||||
echo ">> migrations complete"
|
||||
@@ -0,0 +1,32 @@
|
||||
-- =====================================================================
|
||||
-- Local extension migration: simple employee payment ledger used by the
|
||||
-- Employee Payment Report UI. Backed by the API; not a Supabase migration.
|
||||
-- =====================================================================
|
||||
|
||||
create table if not exists app.employees (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
emp_id text not null unique,
|
||||
name text not null,
|
||||
email text,
|
||||
department text,
|
||||
location text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists app.employee_transactions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references app.employees(id) on delete cascade,
|
||||
transaction_date date not null,
|
||||
collection_amount numeric(18,2) not null default 0,
|
||||
deposit_amount numeric(18,2) not null default 0,
|
||||
currency text not null check (currency in ('USD','LBP')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_emp_tx_emp on app.employee_transactions(employee_id, transaction_date desc);
|
||||
|
||||
-- These tables are owned by the API; RLS off, gated at the HTTP layer.
|
||||
alter table app.employees disable row level security;
|
||||
alter table app.employee_transactions disable row level security;
|
||||
grant select, insert, update, delete on app.employees to authenticated;
|
||||
grant select, insert, update, delete on app.employee_transactions to authenticated;
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Seed (or reset) the default admin user, default shop, owner role, Till 1.
|
||||
set -euo pipefail
|
||||
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@local.test}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-ChangeMe123!}"
|
||||
ADMIN_NAME="${ADMIN_NAME:-Local Admin}"
|
||||
|
||||
echo ">> seeding admin user: ${ADMIN_EMAIL}"
|
||||
|
||||
# Use psql -v to safely substitute values inside the DO block via :'name' --
|
||||
# but :'name' only works at top level. So we generate plain SQL with the
|
||||
# values inlined as quoted literals (escaping single quotes).
|
||||
escape() { printf "%s" "$1" | sed "s/'/''/g"; }
|
||||
EM=$(escape "$ADMIN_EMAIL")
|
||||
PW=$(escape "$ADMIN_PASSWORD")
|
||||
NM=$(escape "$ADMIN_NAME")
|
||||
|
||||
psql -v ON_ERROR_STOP=1 \
|
||||
--username "$POSTGRES_USER" \
|
||||
--dbname "$POSTGRES_DB" <<SQL
|
||||
do \$\$
|
||||
declare
|
||||
v_user_id uuid;
|
||||
v_shop_id uuid;
|
||||
begin
|
||||
insert into auth.users(email, password_hash, full_name, is_active)
|
||||
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true)
|
||||
on conflict (email) do update
|
||||
set password_hash = excluded.password_hash,
|
||||
full_name = excluded.full_name,
|
||||
is_active = true
|
||||
returning id into v_user_id;
|
||||
|
||||
insert into app.user_profiles(user_id, full_name, is_active)
|
||||
values (v_user_id, '${NM}', true)
|
||||
on conflict (user_id) do update
|
||||
set full_name = excluded.full_name,
|
||||
is_active = true;
|
||||
|
||||
insert into app.shops(name, created_by)
|
||||
values ('Default Shop', v_user_id)
|
||||
on conflict do nothing;
|
||||
|
||||
select id into v_shop_id from app.shops where name = 'Default Shop' limit 1;
|
||||
|
||||
insert into app.user_shop_assignments(user_id, shop_id, role, assigned_by)
|
||||
values (v_user_id, v_shop_id, 'owner', v_user_id)
|
||||
on conflict (user_id, shop_id) do update set role = 'owner';
|
||||
|
||||
insert into app.tills(shop_id, name)
|
||||
values (v_shop_id, 'Till 1')
|
||||
on conflict (shop_id, name) do nothing;
|
||||
end \$\$;
|
||||
SQL
|
||||
|
||||
echo ">> admin user ensured: ${ADMIN_EMAIL}"
|
||||
Generated
+1752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "crm-omt-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user