Add internal registry endpoint, self-registration, and workforce sync

- Register internalRoutes at /api/auth/internal (used by management)
- Add registerRoutes for staff self-registration flow
- Add workforce sync job (runs on schedule, fails gracefully if unconfigured)
- Add email.js for registration/invite emails

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-01 20:35:44 +00:00
parent 2d89fd9227
commit f08d53d702
10 changed files with 704 additions and 18 deletions

View file

@ -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

View file

@ -12,6 +12,7 @@
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"nodemailer": "^6.10.1",
"pg": "^8.13.1"
}
}

61
src/email.js Normal file
View file

@ -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" <noreply@hotelnumberfour.com>'
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: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:2rem">
<h2 style="color:#1e3a5f;margin:0 0 1rem">Hotel Number Four</h2>
<p>Hi ${name},</p>
<p>Your staff portal verification code is:</p>
<div style="font-size:2.5rem;font-weight:700;letter-spacing:0.3em;color:#1e3a5f;
background:#f3f4f6;padding:1rem 2rem;border-radius:8px;
text-align:center;margin:1.5rem 0">${pin}</div>
<p style="color:#6b7280;font-size:0.875rem">
This code expires in 15 minutes.<br>
If you did not request access to the staff portal, please ignore this email.
</p>
</div>
`,
})
}

View file

@ -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)

View file

@ -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 })
}
})
}

View file

@ -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,
}
})

32
src/routes/internal.js Normal file
View file

@ -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 })
}
})
}

170
src/routes/register.js Normal file
View file

@ -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
})
}

103
src/sync.js Normal file
View file

@ -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))
}

97
src/workforce.js Normal file
View file

@ -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
}