Add Workforce rota integration to HK Planner

Pulls HK staff shifts from Workforce.com API into the staff rota section.
Rota rows (read-only, WF badge, times+hours per day) appear above manual rows;
manual name inputs get a datalist populated from WF staff. Sync triggered via
button with stale-week detection. Dept selection stored per-app in CategorySettings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 20:47:09 +00:00
parent 490a9a558b
commit b803b01ae1
8 changed files with 498 additions and 27 deletions

View file

@ -4,6 +4,7 @@ import cors from '@fastify/cors'
import { initDb } from './db.js'
import { bookingRoutes } from './routes/bookings.js'
import { configRoutes } from './routes/config.js'
import { workforceRoutes } from './routes/workforce.js'
const app = Fastify({ logger: true, trustProxy: true })
@ -14,6 +15,7 @@ app.get('/health', async () => ({ status: 'healthy' }))
await app.register(bookingRoutes)
await app.register(configRoutes)
await app.register(workforceRoutes)
try {
await initDb()

View file

@ -0,0 +1,124 @@
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080'
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _credsCache = null // { creds, 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('Workforce integration not configured — add bearer token in Settings')
const creds = await res.json()
if (!creds.bearer_token) throw new Error('Workforce integration not configured — add bearer token in Settings')
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
return creds
}
async function wfFetch(path) {
const creds = await getWorkforceCreds()
const base = creds.base_url || 'https://my.workforce.com'
const res = await fetch(`${base}${path}`, {
headers: { Authorization: `Bearer ${creds.bearer_token}` },
signal: AbortSignal.timeout(12000),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Workforce API ${res.status}${body ? ': ' + body.slice(0, 200) : ''}`)
}
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}&page_size=100`)
const items = Array.isArray(data)
? data
: (data.users ?? data.departments ?? data.schedules ?? data.teams ?? [])
results.push(...items)
if (items.length < 100) break
page++
}
return results
}
function fmtTime(unixSecs) {
return new Date(unixSecs * 1000).toLocaleTimeString('en-GB', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/London',
})
}
export async function fetchDepartments() {
const creds = await getWorkforceCreds()
const locationId = creds.location_id ? String(creds.location_id) : null
const all = await wfFetchPaged('/api/v2/departments')
const filtered = locationId ? all.filter(d => String(d.location_id) === locationId) : all
return filtered.map(d => ({ id: String(d.id), name: d.name }))
}
export async function fetchStaff(deptIds) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const path = locationId ? `/api/v2/users?location_id=${locationId}` : '/api/v2/users'
const all = await wfFetchPaged(path)
const active = all.filter(u => u.active !== false)
if (!deptIds || !deptIds.length) return active.map(u => ({ id: String(u.id), name: u.name }))
return active
.filter(u => (u.department_ids ?? []).some(id => deptIds.includes(String(id))))
.map(u => ({ id: String(u.id), name: u.name }))
}
export async function fetchShifts(from, to, deptIds) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const staffList = await fetchStaff(deptIds)
const nameMap = Object.fromEntries(staffList.map(s => [s.id, s.name]))
let path = `/api/v2/schedules?from=${from}&to=${to}`
if (locationId) path += `&location_id=${locationId}`
const schedules = await wfFetchPaged(path)
const filtered = schedules.filter(s => deptIds.includes(String(s.department_id)))
const byUser = {}
for (const s of filtered) {
const uid = String(s.user_id)
if (!nameMap[uid]) continue
const date = new Date(s.start * 1000).toISOString().slice(0, 10)
let breakHrs = 0
if (Array.isArray(s.breaks) && s.breaks.length) {
breakHrs = s.breaks.reduce((sum, b) => sum + (b.finish - b.start) / 3600, 0)
} else {
breakHrs = (s.automatic_break_length ?? 0) / 60
}
const shiftHrs = Math.max(0, (s.finish - s.start) / 3600 - breakHrs)
if (!byUser[uid]) byUser[uid] = {}
if (!byUser[uid][date]) byUser[uid][date] = { hours: 0, shifts: [] }
byUser[uid][date].hours += shiftHrs
byUser[uid][date].shifts.push({ start: s.start, finish: s.finish })
}
return Object.entries(byUser).map(([uid, days]) => ({
id: uid,
name: nameMap[uid],
days: Object.fromEntries(
Object.entries(days).map(([date, data]) => {
const sorted = data.shifts.sort((a, b) => a.start - b.start)
const firstStart = sorted[0].start
const lastFinish = sorted[sorted.length - 1].finish
const times = sorted.length > 1
? `${fmtTime(firstStart)}${fmtTime(lastFinish)} (${sorted.length} shifts)`
: `${fmtTime(firstStart)}${fmtTime(lastFinish)}`
return [date, { hours: parseFloat(data.hours.toFixed(2)), times }]
})
),
}))
}

View file

@ -8,7 +8,7 @@ export async function configRoutes(app) {
// ── GET /api/config — all settings at once ──────────────────────────────
app.get('/api/config', async (req) => {
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins] =
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins, workforceRota, workforceDepts] =
await Promise.all([
getConfig('time_requirements', {}),
getConfig('staff_data', []),
@ -16,6 +16,8 @@ export async function configRoutes(app) {
getConfig('general_tasks', []),
getConfig('last_reviewed', null),
getConfig('tolerance_minutes', 30),
getConfig('workforce_rota', null),
getConfig('workforce_departments', []),
])
const today = new Date()
@ -25,12 +27,14 @@ export async function configRoutes(app) {
const yestStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
return {
time_requirements: timeReqs || {},
staff_data: staffData || [],
pickup_data: pickupData || {},
general_tasks: generalTasks || [],
last_reviewed: lastReviewed || yestStr,
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
time_requirements: timeReqs || {},
staff_data: staffData || [],
pickup_data: pickupData || {},
general_tasks: generalTasks || [],
last_reviewed: lastReviewed || yestStr,
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
workforce_rota: workforceRota || null,
workforce_departments: workforceDepts || [],
}
})
@ -121,6 +125,19 @@ export async function configRoutes(app) {
return { ok: true, date }
})
// ── PUT /api/config/workforce-departments ────────────────────────────────
app.put('/api/config/workforce-departments', { preHandler: requireCap('settings') }, async (req, reply) => {
const { dept_ids } = req.body || {}
if (!Array.isArray(dept_ids)) return reply.status(400).send({ error: 'dept_ids must be an array' })
const clean = dept_ids.filter(id => typeof id === 'string')
await Promise.all([
setConfig('workforce_departments', clean),
setConfig('workforce_staff_cache', null),
])
return { ok: true }
})
// ── GET /api/categories — settings cap required ──────────────────────────
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {

View file

@ -0,0 +1,61 @@
import { requireAuth, requireCap } from '../auth.js'
import { getConfig, setConfig } from '../db.js'
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js'
export async function workforceRoutes(app) {
app.addHook('preHandler', requireAuth)
// ── GET /api/workforce/departments — list depts for this location ────────────
app.get('/api/workforce/departments', { preHandler: requireCap('settings') }, async (req, reply) => {
try {
return await fetchDepartments()
} catch (err) {
const status = err.message.includes('not configured') ? 503 : 502
return reply.status(status).send({ error: err.message })
}
})
// ── POST /api/workforce/sync?start=&end= ─────────────────────────────────────
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
const { start, end } = req.query
if (!start || !end) return reply.status(400).send({ error: 'start and end query params required' })
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) {
return reply.status(400).send({ error: 'No HK departments selected — configure in Category Settings' })
}
try {
const staff = await fetchShifts(start, end, deptIds)
const snapshot = { last_sync: new Date().toISOString(), dates: [start, end], staff }
await setConfig('workforce_rota', snapshot)
return snapshot
} catch (err) {
const status = err.message.includes('not configured') ? 503 : 502
return reply.status(status).send({ error: err.message })
}
})
// ── GET /api/workforce/staff — staff list for manual row datalist ─────────────
app.get('/api/workforce/staff', { preHandler: requireCap('planner') }, async (req, reply) => {
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) return []
const cached = await getConfig('workforce_staff_cache', null)
if (cached && Date.now() - new Date(cached.fetched_at).getTime() < 3_600_000) {
return cached.staff
}
try {
const staff = await fetchStaff(deptIds)
await setConfig('workforce_staff_cache', { fetched_at: new Date().toISOString(), staff })
return staff
} catch (err) {
const status = err.message.includes('not configured') ? 503 : 502
return reply.status(status).send({ error: err.message })
}
})
}