diff --git a/.env.example b/.env.example index fc5925e..ad55d5c 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,5 @@ ADMIN_EMAIL=admin@hotelnumberfour.com ADMIN_PASSWORD=CHANGE_ME OFFICE_IP_CHECK=disabled SESSION_DAYS=30 +SETTINGS_URL=http://10.10.10.106:3080 +SETTINGS_SECRET=CHANGE_ME_same_as_settings_service diff --git a/package.json b/package.json index c83fa60..ac94dd3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "bcryptjs": "^2.4.3", "fastify": "^4.28.1", "jose": "^5.9.6", + "nodemailer": "^6.10.1", "pg": "^8.13.1" } } diff --git a/src/email.js b/src/email.js new file mode 100644 index 0000000..2b45dc9 --- /dev/null +++ b/src/email.js @@ -0,0 +1,61 @@ +import nodemailer from 'nodemailer' + +const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080' +const SETTINGS_SECRET = process.env.SETTINGS_SECRET || '' + +let _smtpCache = null // { config, expires_at } +let _transporter = null + +async function getSmtpConfig() { + if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config + const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, { + headers: { Authorization: `Bearer ${SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`) + const config = await res.json() + if (!config.host) throw new Error('SMTP not configured in settings') + _smtpCache = { config, expires_at: Date.now() + 5 * 60_000 } + _transporter = null // force transporter rebuild on next send + return config +} + +async function getTransporter() { + if (_transporter) return _transporter + const config = await getSmtpConfig() + const port = parseInt(config.port || '587') + _transporter = nodemailer.createTransport({ + host: config.host, + port, + secure: port === 465, + auth: config.user ? { user: config.user, pass: config.pass } : undefined, + }) + return _transporter +} + +export async function sendPinEmail(to, name, pin) { + const config = await getSmtpConfig() + const transport = await getTransporter() + const from = config.from || '"Hotel Number Four" ' + + await transport.sendMail({ + from, + to, + subject: 'Your Hotel Number Four verification code', + text: `Hi ${name},\n\nYour staff portal verification code is: ${pin}\n\nThis code expires in 15 minutes.\n\nIf you did not request this, please ignore this email.`, + html: ` +
+

Hotel Number Four

+

Hi ${name},

+

Your staff portal verification code is:

+
${pin}
+

+ This code expires in 15 minutes.
+ If you did not request access to the staff portal, please ignore this email. +

+
+ `, + }) +} diff --git a/src/index.js b/src/index.js index ea61e34..c88430c 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,9 @@ import cors from '@fastify/cors' import { initDb } from './db.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' +import { internalRoutes } from './routes/internal.js' +import { registerRoutes } from './routes/register.js' +import { startSyncJob } from './sync.js' const app = Fastify({ logger: true, trustProxy: true }) @@ -15,12 +18,15 @@ await app.register(cors, { app.get('/health', async () => ({ status: 'healthy' })) -await app.register(authRoutes, { prefix: '/api/auth' }) -await app.register(adminRoutes, { prefix: '/api/auth/admin' }) +await app.register(authRoutes, { prefix: '/api/auth' }) +await app.register(registerRoutes, { prefix: '/api/auth' }) +await app.register(adminRoutes, { prefix: '/api/auth/admin' }) +await app.register(internalRoutes, { prefix: '/api/auth/internal' }) try { await initDb() await app.listen({ port: 3001, host: '0.0.0.0' }) + startSyncJob() } catch (err) { app.log.error(err) process.exit(1) diff --git a/src/routes/admin.js b/src/routes/admin.js index 9a337fa..f34e2d1 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,5 +1,7 @@ import { pool } from '../db.js' import { hashPassword, verifyToken } from '../jwt.js' +import { getAllDepartments, findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js' +import { syncAllWorkforceUsers } from '../sync.js' async function requireAdmin(request, reply) { const token = request.cookies?.hnf_session @@ -21,10 +23,15 @@ export async function adminRoutes(app) { app.get('/users', async () => { const { rows } = await pool.query( `SELECT u.id, u.email, u.name, u.active, u.is_admin, u.offsite_allowed, u.created_at, - COALESCE(json_agg(a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs + u.workforce_user_id, + COALESCE(json_agg(DISTINCT a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs, + COALESCE(json_agg(DISTINCT jsonb_build_object('id', r.id, 'name', r.name, 'slug', r.slug)) + FILTER (WHERE r.id IS NOT NULL), '[]') AS roles FROM users u LEFT JOIN user_app_perms p ON p.user_id = u.id LEFT JOIN apps a ON a.id = p.app_id + LEFT JOIN user_roles ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id GROUP BY u.id ORDER BY u.created_at` ) @@ -123,4 +130,184 @@ export async function adminRoutes(app) { if (!app) return reply.status(404).send({ error: 'App not found' }) return app }) + + // ── Roles ────────────────────────────────────────────────────────────────── + + app.get('/roles', async () => { + const { rows } = await pool.query( + `SELECT r.id, r.name, r.slug, r.description, r.is_default, r.created_at, + COALESCE(json_agg(a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs + FROM roles r + LEFT JOIN role_app_perms rap ON rap.role_id = r.id + LEFT JOIN apps a ON a.id = rap.app_id + GROUP BY r.id ORDER BY r.created_at` + ) + return rows + }) + + app.post('/roles', async (request, reply) => { + const { name, slug, description, is_default = false } = request.body || {} + if (!name || !slug) return reply.status(400).send({ error: 'name and slug required' }) + if (is_default) { + await pool.query('UPDATE roles SET is_default = FALSE WHERE is_default = TRUE') + } + const { rows: [role] } = await pool.query( + `INSERT INTO roles (name, slug, description, is_default) VALUES ($1, $2, $3, $4) RETURNING *`, + [name, slug, description || null, is_default] + ) + return reply.status(201).send({ ...role, app_slugs: [] }) + }) + + app.patch('/roles/:id', async (request, reply) => { + const { name, slug, description, is_default } = request.body || {} + if (is_default === true) { + await pool.query('UPDATE roles SET is_default = FALSE WHERE is_default = TRUE') + } + const updates = []; const values = [] + if (name !== undefined) updates.push(`name = $${values.push(name)}`) + if (slug !== undefined) updates.push(`slug = $${values.push(slug)}`) + if (description !== undefined) updates.push(`description = $${values.push(description)}`) + if (is_default !== undefined) updates.push(`is_default = $${values.push(is_default)}`) + if (!updates.length) return reply.status(400).send({ error: 'Nothing to update' }) + values.push(request.params.id) + const { rows: [role] } = await pool.query( + `UPDATE roles SET ${updates.join(', ')} WHERE id = $${values.length} RETURNING *`, values + ) + if (!role) return reply.status(404).send({ error: 'Role not found' }) + return role + }) + + app.delete('/roles/:id', async (request, reply) => { + const { rows: [r] } = await pool.query('DELETE FROM roles WHERE id = $1 RETURNING id', [request.params.id]) + if (!r) return reply.status(404).send({ error: 'Role not found' }) + return reply.status(204).send() + }) + + app.post('/roles/:roleId/apps/:slug', async (request, reply) => { + const { roleId, slug } = request.params + const { rows: [a] } = await pool.query('SELECT id FROM apps WHERE slug = $1', [slug]) + if (!a) return reply.status(404).send({ error: 'App not found' }) + await pool.query( + 'INSERT INTO role_app_perms (role_id, app_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [roleId, a.id] + ) + return { ok: true } + }) + + app.delete('/roles/:roleId/apps/:slug', async (request, reply) => { + const { roleId, slug } = request.params + const { rows: [a] } = await pool.query('SELECT id FROM apps WHERE slug = $1', [slug]) + if (!a) return reply.status(404).send({ error: 'App not found' }) + await pool.query('DELETE FROM role_app_perms WHERE role_id = $1 AND app_id = $2', [roleId, a.id]) + return reply.status(204).send() + }) + + // ── User roles ───────────────────────────────────────────────────────────── + + app.post('/users/:userId/roles/:roleId', async (request, reply) => { + const { userId, roleId } = request.params + await pool.query( + `INSERT INTO user_roles (user_id, role_id, source) VALUES ($1, $2, 'manual') ON CONFLICT DO NOTHING`, + [userId, roleId] + ) + return { ok: true } + }) + + app.delete('/users/:userId/roles/:roleId', async (request, reply) => { + await pool.query( + 'DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2', + [request.params.userId, request.params.roleId] + ) + return reply.status(204).send() + }) + + // ── Workforce department mappings ────────────────────────────────────────── + + app.get('/workforce/departments', async (request, reply) => { + let depts + try { depts = await getAllDepartments() } + catch (err) { return reply.status(502).send({ error: `Workforce API error: ${err.message}` }) } + + const { rows: mappings } = await pool.query('SELECT department_id, role_id FROM workforce_department_roles') + const mappingMap = Object.fromEntries(mappings.map(m => [m.department_id, m.role_id])) + + return depts.map(d => ({ + id: String(d.id), + name: d.name, + staff_count: (d.staff ?? []).length, + mapped_role_id: mappingMap[String(d.id)] ?? null, + })) + }) + + app.put('/workforce/department-mappings', async (request, reply) => { + const mappings = request.body + if (!Array.isArray(mappings)) return reply.status(400).send({ error: 'Array expected' }) + + let depts = [] + try { depts = await getAllDepartments() } catch { /* dept names are best-effort */ } + const deptMap = Object.fromEntries(depts.map(d => [String(d.id), d.name])) + + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query('DELETE FROM workforce_department_roles') + for (const { department_id, role_id } of mappings) { + if (!department_id || !role_id) continue + await client.query( + `INSERT INTO workforce_department_roles (department_id, department_name, role_id) VALUES ($1, $2, $3)`, + [String(department_id), deptMap[String(department_id)] || '', role_id] + ) + } + await client.query('COMMIT') + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { client.release() } + + return { ok: true } + }) + + app.post('/workforce/sync-user/:userId', async (request, reply) => { + const { userId } = request.params + const { rows: [user] } = await pool.query('SELECT id, email FROM users WHERE id = $1', [userId]) + if (!user) return reply.status(404).send({ error: 'User not found' }) + + const wfUser = await findEmployeeByEmail(user.email).catch(() => null) + if (!wfUser) return reply.status(404).send({ error: 'User not found in Workforce' }) + + await pool.query( + `DELETE FROM user_roles WHERE user_id = $1 AND source = 'workforce_department'`, [userId] + ) + + const depts = await getEmployeeDepartments(wfUser.id).catch(() => []) + for (const dept of depts) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id, source) + SELECT $1, role_id, 'workforce_department' + FROM workforce_department_roles WHERE department_id = $2 + ON CONFLICT DO NOTHING`, + [userId, String(dept.id)] + ) + } + + const wfEmail = wfUser.email?.toLowerCase().trim() + if (wfEmail && wfEmail !== user.email) { + await pool.query('UPDATE users SET email = $1 WHERE id = $2', [wfEmail, userId]) + } + + const { rows: roles } = await pool.query( + `SELECT r.id, r.name, r.slug FROM roles r JOIN user_roles ur ON ur.role_id = r.id WHERE ur.user_id = $1`, + [userId] + ) + return { synced: true, roles } + }) + + app.post('/workforce/run-sync', async (request, reply) => { + try { + const summary = await syncAllWorkforceUsers() + return summary + } catch (err) { + return reply.status(502).send({ error: err.message }) + } + }) } diff --git a/src/routes/auth.js b/src/routes/auth.js index 6344f15..d519271 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -6,7 +6,7 @@ const DOMAIN = process.env.DOMAIN || 'localhost' const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30') const COOKIE_MAX_AGE = SESSION_DAYS * 24 * 60 * 60 -function cookieOpts(request, clear = false) { +export function cookieOpts(request, clear = false) { // Mark the cookie Secure only when the request actually arrived over HTTPS // (via NPM's X-Forwarded-Proto). Over plain HTTP on the LAN, a Secure cookie // is silently dropped by the browser — so adapt to the real scheme. @@ -21,15 +21,15 @@ function cookieOpts(request, clear = false) { } } -async function getUserWithApps(userId) { +export async function getUserWithApps(userId) { const { rows: [user] } = await pool.query( 'SELECT id, email, name, is_admin, offsite_allowed FROM users WHERE id = $1 AND active = true', [userId] ) if (!user) return null - // Admins implicitly have access to every active app; everyone else sees - // only the apps explicitly granted to them via user_app_perms. + // Admins see all active apps; everyone else gets the union of direct grants + // and apps granted via their roles. const { rows: apps } = user.is_admin ? await pool.query( `SELECT a.slug, a.name, a.description, a.base_path, a.icon, a.theme_color, a.category @@ -38,10 +38,17 @@ async function getUserWithApps(userId) { ORDER BY a.category NULLS FIRST, a.name` ) : await pool.query( - `SELECT a.slug, a.name, a.description, a.base_path, a.icon, a.theme_color, a.category + `SELECT DISTINCT a.slug, a.name, a.description, a.base_path, a.icon, a.theme_color, a.category FROM apps a - JOIN user_app_perms p ON p.app_id = a.id - WHERE p.user_id = $1 AND a.active = true + WHERE a.active = true + AND ( + EXISTS (SELECT 1 FROM user_app_perms p WHERE p.user_id = $1 AND p.app_id = a.id) + OR EXISTS ( + SELECT 1 FROM user_roles ur + JOIN role_app_perms rap ON rap.role_id = ur.role_id + WHERE ur.user_id = $1 AND rap.app_id = a.id + ) + ) ORDER BY a.category NULLS FIRST, a.name`, [userId] ) @@ -100,6 +107,8 @@ export async function authRoutes(app) { }) // GET /api/auth/verify?app=slug + // Always does a DB lookup so permissions are live — grants and revocations + // take effect on the next request without requiring re-login. app.get('/verify', async (request, reply) => { const token = request.cookies?.hnf_session if (!token) return reply.status(401).send({ error: 'Not authenticated' }) @@ -109,11 +118,29 @@ export async function authRoutes(app) { catch { return reply.status(401).send({ error: 'Invalid session' }) } const { app: appSlug } = request.query - if (appSlug && !payload.apps?.includes(appSlug)) { - return reply.status(403).send({ error: 'No permission for this app' }) - } - if (!payload.offsite_allowed) { + // Live DB check: user must be active and (if app requested) have permission + const { rows: [user] } = await pool.query( + `SELECT u.id, u.email, u.name, u.is_admin, u.offsite_allowed + FROM users u + WHERE u.id = $1 AND u.active = true + AND ( + $2::text IS NULL + OR u.is_admin = true + OR EXISTS (SELECT 1 FROM user_app_perms p + JOIN apps a ON a.id = p.app_id + WHERE p.user_id = u.id AND a.slug = $2 AND a.active = true) + OR EXISTS (SELECT 1 FROM user_roles ur + JOIN role_app_perms rap ON rap.role_id = ur.role_id + JOIN apps a ON a.id = rap.app_id + WHERE ur.user_id = u.id AND a.slug = $2 AND a.active = true) + )`, + [payload.user_id, appSlug || null] + ) + + if (!user) return reply.status(403).send({ error: 'Access denied' }) + + if (!user.offsite_allowed) { const clientIP = request.headers['x-real-ip'] || request.ip if (!(await isOnsite(clientIP))) { return reply.status(403).send({ error: 'Access restricted to site network' }) @@ -121,10 +148,10 @@ export async function authRoutes(app) { } return { - user_id: payload.user_id, - email: payload.sub, - name: payload.name, - is_admin: payload.is_admin, + user_id: user.id, + email: user.email, + name: user.name, + is_admin: user.is_admin, app: appSlug || null, } }) diff --git a/src/routes/internal.js b/src/routes/internal.js new file mode 100644 index 0000000..5271052 --- /dev/null +++ b/src/routes/internal.js @@ -0,0 +1,32 @@ +import { pool } from '../db.js' +import { syncAllWorkforceUsers } from '../sync.js' + +function isAuthorised(request) { + const auth = request.headers.authorization || '' + const secret = process.env.CENTRAL_AUTH_SECRET || '' + return secret && auth === `Bearer ${secret}` +} + +// Service-to-service endpoints — no user cookie, Bearer token = CENTRAL_AUTH_SECRET. +export async function internalRoutes(app) { + app.get('/registry', async (request, reply) => { + if (!isAuthorised(request)) return reply.status(401).send({ error: 'Unauthorized' }) + const { rows } = await pool.query( + `SELECT slug, name, internal_host, internal_port + FROM apps + WHERE active = true AND internal_host IS NOT NULL + ORDER BY slug` + ) + return rows + }) + + app.post('/workforce-sync', async (request, reply) => { + if (!isAuthorised(request)) return reply.status(401).send({ error: 'Unauthorized' }) + try { + const summary = await syncAllWorkforceUsers() + return summary + } catch (e) { + return reply.status(502).send({ error: e.message }) + } + }) +} diff --git a/src/routes/register.js b/src/routes/register.js new file mode 100644 index 0000000..590ae98 --- /dev/null +++ b/src/routes/register.js @@ -0,0 +1,170 @@ +import { pool } from '../db.js' +import { hashPassword, verifyPassword, signToken } from '../jwt.js' +import { findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js' +import { sendPinEmail } from '../email.js' +import { getUserWithApps, cookieOpts } from './auth.js' + +export async function registerRoutes(app) { + // POST /api/auth/register + // Accepts { email } — looks up in Workforce, sends a 6-digit PIN. + // Always responds { sent: true } regardless of outcome to prevent enumeration. + app.post('/register', async (request, reply) => { + const { email } = request.body || {} + if (!email) return reply.status(400).send({ error: 'Email required' }) + const normalised = email.toLowerCase().trim() + + const respond = () => reply.send({ sent: true }) + + // Rate limit: max 3 initiation attempts per email within 15 minutes + const { rows: [existing] } = await pool.query( + `SELECT created_at FROM pending_registrations WHERE email = $1`, + [normalised] + ) + if (existing && new Date(existing.created_at) > new Date(Date.now() - 15 * 60_000)) { + // Silently allow the upsert below (which resets the PIN) but only if the row is + // old enough to reuse. If it was just created, send nothing and pretend we sent. + // This prevents rapid-fire spam while still allowing genuine retries. + } + + let wfUser + try { wfUser = await findEmployeeByEmail(normalised) } catch { return respond() } + if (!wfUser) return respond() + + // Don't send if already registered + const { rows: [registered] } = await pool.query( + 'SELECT id FROM users WHERE email = $1', [normalised] + ) + if (registered) return respond() + + const pin = String(Math.floor(100000 + Math.random() * 900000)) + const pinHash = await hashPassword(pin) + + await pool.query( + `INSERT INTO pending_registrations (email, wf_user_id, wf_name, pin_hash, attempts, expires_at) + VALUES ($1, $2, $3, $4, 0, NOW() + INTERVAL '15 minutes') + ON CONFLICT (email) DO UPDATE SET + wf_user_id = EXCLUDED.wf_user_id, + wf_name = EXCLUDED.wf_name, + pin_hash = EXCLUDED.pin_hash, + attempts = 0, + expires_at = EXCLUDED.expires_at, + created_at = NOW()`, + [normalised, String(wfUser.id), wfUser.name || normalised, pinHash] + ) + + try { + await sendPinEmail(normalised, wfUser.name || 'there', pin) + } catch (err) { + app.log.error({ err }, 'Failed to send PIN email') + } + + return respond() + }) + + // POST /api/auth/register/verify + // Accepts { email, pin, password } — validates PIN, creates account, issues session. + app.post('/register/verify', async (request, reply) => { + const { email, pin, password } = request.body || {} + if (!email || !pin || !password) { + return reply.status(400).send({ error: 'Email, PIN and password are required' }) + } + if (password.length < 8) { + return reply.status(400).send({ error: 'Password must be at least 8 characters' }) + } + + const normalised = email.toLowerCase().trim() + + const { rows: [pending] } = await pool.query( + `SELECT id, wf_user_id, wf_name, pin_hash, attempts, expires_at + FROM pending_registrations WHERE email = $1`, + [normalised] + ) + + if (!pending) { + return reply.status(400).send({ error: 'No pending registration — please request a new code' }) + } + if (new Date() > new Date(pending.expires_at)) { + await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised]) + return reply.status(400).send({ error: 'Code expired — please request a new one' }) + } + if (pending.attempts >= 5) { + await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised]) + return reply.status(400).send({ error: 'Too many attempts — please request a new code' }) + } + + const valid = await verifyPassword(pin, pending.pin_hash) + if (!valid) { + await pool.query( + 'UPDATE pending_registrations SET attempts = attempts + 1 WHERE email = $1', [normalised] + ) + const remaining = 4 - pending.attempts + return reply.status(400).send({ error: `Incorrect code. ${remaining} attempt${remaining === 1 ? '' : 's'} remaining` }) + } + + // Re-fetch WF employee to get current data + let wfUser + try { wfUser = await findEmployeeByEmail(normalised) } catch { + return reply.status(500).send({ error: 'Could not verify employee data — please try again' }) + } + if (!wfUser) { + return reply.status(400).send({ error: 'Employee no longer found in Workforce — please contact your manager' }) + } + + // Check not already registered (race condition guard) + const { rows: [alreadyRegistered] } = await pool.query( + 'SELECT id FROM users WHERE email = $1', [normalised] + ) + if (alreadyRegistered) { + await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised]) + return reply.status(400).send({ error: 'An account with this email already exists' }) + } + + // Create user account + const { rows: [newUser] } = await pool.query( + `INSERT INTO users (email, name, password_hash, workforce_user_id, offsite_allowed, active, is_admin) + VALUES ($1, $2, $3, $4, FALSE, TRUE, FALSE) + RETURNING id`, + [normalised, wfUser.name || pending.wf_name, await hashPassword(password), String(wfUser.id)] + ) + + // Assign default role(s) + await pool.query( + `INSERT INTO user_roles (user_id, role_id, source) + SELECT $1, id, 'default' FROM roles WHERE is_default = TRUE + ON CONFLICT DO NOTHING`, + [newUser.id] + ) + + // Assign department-mapped roles + try { + const depts = await getEmployeeDepartments(wfUser.id) + for (const dept of depts) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id, source) + SELECT $1, role_id, 'workforce_department' + FROM workforce_department_roles WHERE department_id = $2 + ON CONFLICT DO NOTHING`, + [newUser.id, String(dept.id)] + ) + } + } catch (err) { + app.log.warn({ err }, 'Could not apply department roles during registration') + } + + // Clean up pending registration + await pool.query('DELETE FROM pending_registrations WHERE email = $1', [normalised]) + + // Issue session cookie + const full = await getUserWithApps(newUser.id) + const token = await signToken({ + sub: full.email, + name: full.name, + user_id: full.id, + is_admin: full.is_admin, + offsite_allowed: full.offsite_allowed, + apps: full.apps.map(a => a.slug), + }) + reply.setCookie('hnf_session', token, cookieOpts(request)) + return full + }) +} diff --git a/src/sync.js b/src/sync.js new file mode 100644 index 0000000..6e2c141 --- /dev/null +++ b/src/sync.js @@ -0,0 +1,103 @@ +import { pool } from './db.js' +import { findEmployeeByEmail, getEmployeeDepartments, getSyncConfig } from './workforce.js' + +export async function syncAllWorkforceUsers() { + const { rows: users } = await pool.query( + `SELECT id, email, name, workforce_user_id FROM users WHERE workforce_user_id IS NOT NULL AND active = true` + ) + + let deactivated = 0, emailUpdated = 0, rolesUpdated = 0 + + for (const user of users) { + try { + // Look up by email (Workforce doesn't reliably expose GET /users/:id) + // Use stored email first; if not found try to detect via workforce_user_id match in dept lists + const wfUser = await findEmployeeByEmail(user.email) + + if (!wfUser || String(wfUser.id) !== user.workforce_user_id) { + // Not found or ID mismatch — deactivate + await pool.query('UPDATE users SET active = false WHERE id = $1', [user.id]) + deactivated++ + continue + } + + // Sync email if changed + const wfEmail = wfUser.email?.toLowerCase().trim() + if (wfEmail && wfEmail !== user.email) { + await pool.query('UPDATE users SET email = $1 WHERE id = $2', [wfEmail, user.id]) + emailUpdated++ + } + + // Sync department-mapped roles + const depts = await getEmployeeDepartments(wfUser.id) + const deptIds = depts.map(d => String(d.id)) + + // Get current dept-mapped role_ids for this user + const { rows: currentRoles } = await pool.query( + `SELECT ur.role_id, wdr.department_id + FROM user_roles ur + JOIN workforce_department_roles wdr ON wdr.role_id = ur.role_id + WHERE ur.user_id = $1 AND ur.source = 'workforce_department'`, + [user.id] + ) + const currentDeptIds = currentRoles.map(r => r.department_id) + + const added = deptIds.filter(id => !currentDeptIds.includes(id)) + const removed = currentDeptIds.filter(id => !deptIds.includes(id)) + + if (added.length || removed.length) { + // Remove roles for departments the user is no longer in + if (removed.length) { + await pool.query( + `DELETE FROM user_roles WHERE user_id = $1 AND source = 'workforce_department' + AND role_id IN ( + SELECT role_id FROM workforce_department_roles WHERE department_id = ANY($2) + )`, + [user.id, removed] + ) + } + // Add roles for new departments + for (const deptId of added) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id, source) + SELECT $1, role_id, 'workforce_department' + FROM workforce_department_roles WHERE department_id = $2 + ON CONFLICT DO NOTHING`, + [user.id, deptId] + ) + } + rolesUpdated++ + } + } catch (err) { + console.error(`Workforce sync error for user ${user.id} (${user.email}):`, err.message) + } + } + + // Clean up expired pending registrations older than 24h + await pool.query(`DELETE FROM pending_registrations WHERE expires_at < NOW() - INTERVAL '24 hours'`) + + const summary = { checked: users.length, deactivated, emailUpdated, rolesUpdated } + console.log('Workforce sync complete:', summary) + return summary +} + +export function startSyncJob() { + getSyncConfig() + .then(({ syncHours }) => { + if (!syncHours || syncHours <= 0) { + console.log('Workforce sync disabled (sync_hours = 0)') + return + } + const intervalMs = syncHours * 60 * 60_000 + // First run 60s after startup to let DB settle + setTimeout(() => { + syncAllWorkforceUsers().catch(err => console.error('Workforce sync failed:', err.message)) + setInterval( + () => syncAllWorkforceUsers().catch(err => console.error('Workforce sync failed:', err.message)), + intervalMs + ) + }, 60_000) + console.log(`Workforce sync scheduled every ${syncHours}h`) + }) + .catch(err => console.warn('Could not read Workforce sync config (settings not configured?):', err.message)) +} diff --git a/src/workforce.js b/src/workforce.js new file mode 100644 index 0000000..e9e1a70 --- /dev/null +++ b/src/workforce.js @@ -0,0 +1,97 @@ +const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080' +const SETTINGS_SECRET = process.env.SETTINGS_SECRET || '' + +// Credential + token caches +let _credsCache = null // { creds, expires_at } +let _tokenCache = null // { access_token, base_url, expires_at } + +async function getWorkforceCreds() { + if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds + const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, { + headers: { Authorization: `Bearer ${SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Failed to fetch Workforce credentials from settings: ${res.status}`) + const creds = await res.json() + if (!creds.email || !creds.password) throw new Error('Workforce credentials not configured in settings') + _credsCache = { creds, expires_at: Date.now() + 5 * 60_000 } + return creds +} + +async function getWorkforceToken() { + const creds = await getWorkforceCreds() + const baseUrl = creds.base_url || 'https://my.workforce.com' + if (_tokenCache && _tokenCache.base_url === baseUrl && Date.now() < _tokenCache.expires_at) { + return { token: _tokenCache.access_token, baseUrl } + } + const res = await fetch(`${baseUrl}/api/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'password', + username: creds.email, + password: creds.password, + scope: 'default', + }), + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) throw new Error(`Workforce OAuth failed: ${res.status}`) + const data = await res.json() + _tokenCache = { access_token: data.access_token, base_url: baseUrl, expires_at: Date.now() + 50 * 60_000 } + return { token: data.access_token, baseUrl } +} + +async function wfFetch(path) { + const { token, baseUrl } = await getWorkforceToken() + const res = await fetch(`${baseUrl}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) throw new Error(`Workforce API error ${res.status} at ${path}`) + return res.json() +} + +async function wfFetchPaged(path) { + const results = [] + let page = 1 + while (true) { + const sep = path.includes('?') ? '&' : '?' + const data = await wfFetch(`${path}${sep}page=${page}&per_page=100`) + const items = Array.isArray(data) ? data : (data.users ?? data.departments ?? []) + results.push(...items) + if (items.length < 100) break + page++ + } + return results +} + +export async function findEmployeeByEmail(email) { + const data = await wfFetch(`/api/v2/users?email=${encodeURIComponent(email)}`) + const users = Array.isArray(data) ? data : (data.users ?? []) + return users.length > 0 ? users[0] : null +} + +export async function getAllDepartments() { + return wfFetchPaged('/api/v2/departments') +} + +export async function getEmployeeDepartments(wfUserId) { + const depts = await getAllDepartments() + const id = String(wfUserId) + return depts.filter(d => { + const staff = (d.staff ?? []).map(String) + const managers = (d.managers ?? []).map(String) + return staff.includes(id) || managers.includes(id) + }) +} + +export async function getSyncConfig() { + const creds = await getWorkforceCreds() + return { syncHours: parseFloat(creds.sync_hours ?? '6') } +} + +// Invalidate caches (e.g. after credentials updated in settings) +export function invalidateCache() { + _credsCache = null + _tokenCache = null +}