feat(api): add staff order detail and compliance eligibility

This commit is contained in:
zouantchaw
2026-03-19 20:17:48 +01:00
parent 4d74fa52ab
commit d2bcb9f3ba
18 changed files with 1051 additions and 42 deletions

View File

@@ -0,0 +1,39 @@
function dedupeStrings(values = []) {
return [...new Set(
values
.filter((value) => typeof value === 'string')
.map((value) => value.trim())
.filter(Boolean)
)];
}
export function dedupeDocumentNames(values = []) {
return dedupeStrings(values);
}
export function buildStaffOrderEligibilityBlockers({
hasActiveWorkforce = true,
businessBlockReason = null,
hasExistingParticipation = false,
missingDocumentNames = [],
} = {}) {
const blockers = [];
if (!hasActiveWorkforce) {
blockers.push('Workforce profile is not active');
}
if (businessBlockReason !== null && businessBlockReason !== undefined) {
blockers.push(businessBlockReason
? `You are blocked from working for this client: ${businessBlockReason}`
: 'You are blocked from working for this client');
}
if (hasExistingParticipation) {
blockers.push('You already applied to or booked this order');
}
blockers.push(...dedupeDocumentNames(missingDocumentNames).map((name) => `Missing required document: ${name}`));
return dedupeStrings(blockers);
}

View File

@@ -17,6 +17,7 @@ import {
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
getStaffOrderDetail,
listGeofenceIncidents,
getReportSummary,
getSavings,
@@ -85,6 +86,7 @@ const defaultQueryService = {
getForecastReport,
getNoShowReport,
getOrderReorderPreview,
getStaffOrderDetail,
listGeofenceIncidents,
getReportSummary,
getSavings,
@@ -147,6 +149,17 @@ function requireQueryParam(name, value) {
return value;
}
function requireUuid(value, field) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
const error = new Error(`${field} must be a UUID`);
error.code = 'VALIDATION_ERROR';
error.status = 400;
error.details = { field };
throw error;
}
return value;
}
export function createMobileQueryRouter(queryService = defaultQueryService) {
const router = Router();
@@ -566,6 +579,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
}
});
router.get('/staff/orders/:orderId', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const data = await queryService.getStaffOrderDetail(req.actor.uid, requireUuid(req.params.orderId, 'orderId'));
return res.status(200).json({ ...data, requestId: req.requestId });
} catch (error) {
return next(error);
}
});
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try {
const items = await queryService.listPendingAssignments(req.actor.uid);

View File

@@ -1,4 +1,5 @@
import { AppError } from '../lib/errors.js';
import { buildStaffOrderEligibilityBlockers, dedupeDocumentNames } from '../lib/staff-order-eligibility.js';
import { FAQ_CATEGORIES } from '../data/faqs.js';
import { query } from './db.js';
import { requireClientContext, requireStaffContext } from './actor-context.js';
@@ -98,6 +99,136 @@ function weekdayCodeInTimeZone(value, timeZone = 'UTC') {
return label.slice(0, 3).toUpperCase();
}
function formatCurrencyCents(cents) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format((Number(cents || 0) / 100));
}
function managerDisplayRole(manager) {
if (manager?.role) return manager.role;
if (manager?.businessRole === 'owner') return 'Business Owner';
return 'Hub Manager';
}
export function summarizeStaffOrderDetail({
rows,
managers = [],
blockers = [],
}) {
if (!Array.isArray(rows) || rows.length === 0) {
throw new AppError('NOT_FOUND', 'Order is not available for this staff worker', 404);
}
const firstRow = rows[0];
const timeZone = resolveTimeZone(firstRow.timezone);
const orderedRows = [...rows].sort((left, right) => (
new Date(left.startsAt).getTime() - new Date(right.startsAt).getTime()
));
const firstShift = orderedRows[0];
const lastShift = orderedRows[orderedRows.length - 1];
const daysOfWeek = [...new Set(orderedRows.map((row) => weekdayCodeInTimeZone(row.startsAt, timeZone)))];
const requiredWorkerCount = orderedRows.reduce(
(sum, row) => sum + Number(row.requiredWorkerCount || 0),
0
);
const filledCount = orderedRows.reduce(
(sum, row) => sum + Number(row.filledCount || 0),
0
);
const dispatchPriority = orderedRows.reduce(
(min, row) => Math.min(min, Number(row.dispatchPriority || 3)),
3
);
const dispatchTeam = dispatchPriority === 1
? 'CORE'
: dispatchPriority === 2
? 'CERTIFIED_LOCATION'
: 'MARKETPLACE';
const hasOpenVacancy = orderedRows.some((row) => (
row.shiftStatus === 'OPEN'
&& Number(row.filledCount || 0) < Number(row.requiredWorkerCount || 0)
));
const allCancelled = orderedRows.every((row) => row.shiftStatus === 'CANCELLED');
const allCompleted = orderedRows.every((row) => row.shiftStatus === 'COMPLETED');
let status = 'FILLED';
if (firstRow.orderStatus === 'CANCELLED') status = 'CANCELLED';
else if (firstRow.orderStatus === 'COMPLETED') status = 'COMPLETED';
else if (hasOpenVacancy) status = 'OPEN';
else if (allCancelled) status = 'CANCELLED';
else if (allCompleted) status = 'COMPLETED';
const uniqueManagers = Array.from(
new Map(
managers.map((manager) => {
const key = [
manager.name || '',
manager.phone || '',
managerDisplayRole(manager),
].join('|');
return [key, {
name: manager.name || null,
phone: manager.phone || null,
role: managerDisplayRole(manager),
}];
})
).values()
);
const uniqueBlockers = [...new Set(blockers.filter(Boolean))];
return {
orderId: firstRow.orderId,
orderType: firstRow.orderType,
roleId: firstRow.roleId,
roleCode: firstRow.roleCode,
roleName: firstRow.roleName,
clientName: firstRow.clientName,
businessId: firstRow.businessId,
instantBook: orderedRows.every((row) => Boolean(row.instantBook)),
dispatchTeam,
dispatchPriority,
jobDescription: firstRow.jobDescription || `${firstRow.roleName} shift at ${firstRow.clientName}`,
instructions: firstRow.instructions || null,
status,
schedule: {
totalShifts: firstRow.orderType === 'PERMANENT' ? null : orderedRows.length,
startDate: formatDateInTimeZone(firstShift.startsAt, timeZone),
endDate: formatDateInTimeZone(lastShift.startsAt, timeZone),
daysOfWeek,
startTime: formatTimeInTimeZone(firstShift.startsAt, timeZone),
endTime: formatTimeInTimeZone(firstShift.endsAt, timeZone),
timezone: timeZone,
firstShiftStartsAt: firstShift.startsAt,
lastShiftEndsAt: lastShift.endsAt,
},
location: {
name: firstRow.locationName || null,
address: firstRow.locationAddress || null,
latitude: firstRow.latitude == null ? null : Number(firstRow.latitude),
longitude: firstRow.longitude == null ? null : Number(firstRow.longitude),
},
pay: {
hourlyRateCents: Number(firstRow.hourlyRateCents || 0),
hourlyRate: formatCurrencyCents(firstRow.hourlyRateCents || 0),
},
staffing: {
requiredWorkerCount,
filledCount,
},
managers: uniqueManagers,
eligibility: {
isEligible: uniqueBlockers.length === 0 && status === 'OPEN',
blockers: uniqueBlockers,
},
};
}
function computeReliabilityScore({
totalShifts,
noShowCount,
@@ -1232,6 +1363,187 @@ export async function listAvailableOrders(actorUid, { limit, search } = {}) {
});
}
export async function getStaffOrderDetail(actorUid, orderId) {
const context = await requireStaffContext(actorUid);
const roleCode = context.staff.primaryRole || 'BARISTA';
const rowsResult = await query(
`
SELECT
o.id AS "orderId",
o.business_id AS "businessId",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
o.status AS "orderStatus",
COALESCE(sr.role_id, rc.id) AS "roleId",
COALESCE(sr.role_code, rc.code) AS "roleCode",
COALESCE(sr.role_name, rc.name) AS "roleName",
b.business_name AS "clientName",
COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS "instantBook",
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
COALESCE(dispatch.priority, 3) AS "dispatchPriority",
o.description AS "jobDescription",
o.notes AS instructions,
s.id AS "shiftId",
s.status AS "shiftStatus",
s.starts_at AS "startsAt",
s.ends_at AS "endsAt",
COALESCE(s.timezone, 'UTC') AS timezone,
COALESCE(cp.label, s.location_name, o.location_name) AS "locationName",
COALESCE(s.location_address, cp.address, o.location_address) AS "locationAddress",
COALESCE(s.latitude, cp.latitude, o.latitude) AS latitude,
COALESCE(s.longitude, cp.longitude, o.longitude) AS longitude,
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
sr.workers_needed::INTEGER AS "requiredWorkerCount",
sr.assigned_count::INTEGER AS "filledCount",
cp.id AS "hubId"
FROM orders o
JOIN shifts s ON s.order_id = o.id
JOIN shift_roles sr ON sr.shift_id = s.id
LEFT JOIN roles_catalog rc
ON rc.tenant_id = o.tenant_id
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
JOIN businesses b ON b.id = o.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN LATERAL (
SELECT
dtm.team_type,
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END AS priority
FROM dispatch_team_memberships dtm
WHERE dtm.tenant_id = $1
AND dtm.business_id = s.business_id
AND dtm.staff_id = $4
AND dtm.status = 'ACTIVE'
AND dtm.effective_at <= NOW()
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
ORDER BY
CASE dtm.team_type
WHEN 'CORE' THEN 1
WHEN 'CERTIFIED_LOCATION' THEN 2
ELSE 3
END ASC,
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
dtm.created_at ASC
LIMIT 1
) dispatch ON TRUE
WHERE o.tenant_id = $1
AND o.id = $2
AND s.starts_at > NOW()
AND COALESCE(sr.role_code, rc.code) = $3
ORDER BY s.starts_at ASC
`,
[context.tenant.tenantId, orderId, roleCode, context.staff.staffId]
);
if (rowsResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order is not available for this staff worker', 404, {
orderId,
});
}
const firstRow = rowsResult.rows[0];
const hubIds = [...new Set(rowsResult.rows.map((row) => row.hubId).filter(Boolean))];
const [managerResult, blockedResult, participationResult, missingDocumentResult] = await Promise.all([
hubIds.length === 0
? Promise.resolve({ rows: [] })
: query(
`
SELECT
COALESCE(
NULLIF(TRIM(CONCAT_WS(' ', bm.metadata->>'firstName', bm.metadata->>'lastName')), ''),
u.display_name,
u.email,
bm.invited_email
) AS name,
COALESCE(u.phone, bm.metadata->>'phone') AS phone,
bm.business_role AS "businessRole"
FROM hub_managers hm
JOIN business_memberships bm ON bm.id = hm.business_membership_id
LEFT JOIN users u ON u.id = bm.user_id
WHERE hm.tenant_id = $1
AND hm.hub_id = ANY($2::uuid[])
ORDER BY name ASC
`,
[context.tenant.tenantId, hubIds]
),
query(
`
SELECT reason
FROM staff_blocks
WHERE tenant_id = $1
AND business_id = $2
AND staff_id = $3
LIMIT 1
`,
[context.tenant.tenantId, firstRow.businessId, context.staff.staffId]
),
query(
`
SELECT 1
FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id
LEFT JOIN applications a
ON a.shift_role_id = sr.id
AND a.staff_id = $3
AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED')
LEFT JOIN assignments ass
ON ass.shift_role_id = sr.id
AND ass.staff_id = $3
AND ass.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
LEFT JOIN roles_catalog rc
ON rc.tenant_id = s.tenant_id
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
WHERE s.tenant_id = $1
AND s.order_id = $2
AND s.starts_at > NOW()
AND COALESCE(sr.role_code, rc.code) = $4
AND (a.id IS NOT NULL OR ass.id IS NOT NULL)
LIMIT 1
`,
[context.tenant.tenantId, orderId, context.staff.staffId, roleCode]
),
query(
`
SELECT d.name
FROM documents d
WHERE d.tenant_id = $1
AND d.required_for_role_code = $2
AND d.document_type <> 'ATTIRE'
AND NOT EXISTS (
SELECT 1
FROM staff_documents sd
WHERE sd.tenant_id = d.tenant_id
AND sd.staff_id = $3
AND sd.document_id = d.id
AND sd.status = 'VERIFIED'
)
ORDER BY d.name ASC
`,
[context.tenant.tenantId, firstRow.roleCode, context.staff.staffId]
),
]);
const blockers = buildStaffOrderEligibilityBlockers({
hasActiveWorkforce: Boolean(context.staff.workforceId),
businessBlockReason: blockedResult.rowCount > 0 ? blockedResult.rows[0].reason || null : null,
hasExistingParticipation: participationResult.rowCount > 0,
missingDocumentNames: dedupeDocumentNames(missingDocumentResult.rows.map((row) => row.name)),
});
return summarizeStaffOrderDetail({
rows: rowsResult.rows,
managers: managerResult.rows.map((manager) => ({
...manager,
role: managerDisplayRole(manager),
})),
blockers,
});
}
export async function listOpenShifts(actorUid, { limit, search } = {}) {
const context = await requireStaffContext(actorUid);
const result = await query(

View File

@@ -27,6 +27,7 @@ function createMobileQueryService() {
getSpendReport: async () => ({ totals: { amountCents: 2000 } }),
getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]),
getStaffDashboard: async () => ({ staffName: 'Ana Barista' }),
getStaffOrderDetail: async () => ({ orderId: 'order-available-1', eligibility: { isEligible: true, blockers: [] } }),
getStaffReliabilityStats: async () => ({ totalShifts: 12, reliabilityScore: 96.4 }),
getStaffProfileCompletion: async () => ({ completed: true }),
getStaffSession: async () => ({ staff: { staffId: 's1' } }),
@@ -135,6 +136,27 @@ test('GET /query/staff/orders/available returns injected order-level opportuniti
assert.equal(res.body.items[0].roleId, 'role-catalog-1');
});
test('GET /query/staff/orders/:orderId returns injected order detail', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/orders/11111111-1111-4111-8111-111111111111')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 200);
assert.equal(res.body.orderId, 'order-available-1');
assert.equal(res.body.eligibility.isEligible, true);
});
test('GET /query/staff/orders/:orderId validates uuid', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)
.get('/query/staff/orders/not-a-uuid')
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 400);
assert.equal(res.body.code, 'VALIDATION_ERROR');
});
test('GET /query/client/shifts/scheduled returns injected shift timeline items', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app)

View File

@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { summarizeStaffOrderDetail } from '../src/services/mobile-query-service.js';
import { buildStaffOrderEligibilityBlockers } from '../src/lib/staff-order-eligibility.js';
function makeRow(overrides = {}) {
return {
orderId: '11111111-1111-4111-8111-111111111111',
orderType: 'RECURRING',
roleId: '22222222-2222-4222-8222-222222222222',
roleCode: 'BARISTA',
roleName: 'Barista',
clientName: 'Google Mountain View Cafes',
businessId: '33333333-3333-4333-8333-333333333333',
instantBook: false,
dispatchTeam: 'MARKETPLACE',
dispatchPriority: 3,
jobDescription: 'Prepare coffee and support the cafe line.',
instructions: 'Arrive 15 minutes early.',
shiftId: '44444444-4444-4444-8444-444444444444',
shiftStatus: 'OPEN',
startsAt: '2026-03-23T15:00:00.000Z',
endsAt: '2026-03-23T23:00:00.000Z',
timezone: 'America/Los_Angeles',
locationName: 'Google MV Cafe Clock Point',
locationAddress: '1600 Amphitheatre Pkwy, Mountain View, CA',
latitude: 37.4221,
longitude: -122.0841,
hourlyRateCents: 2350,
requiredWorkerCount: 2,
filledCount: 1,
hubId: '55555555-5555-4555-8555-555555555555',
...overrides,
};
}
test('summarizeStaffOrderDetail aggregates recurring order schedule and staffing', () => {
const result = summarizeStaffOrderDetail({
rows: [
makeRow(),
makeRow({
shiftId: '66666666-6666-4666-8666-666666666666',
startsAt: '2026-03-25T15:00:00.000Z',
endsAt: '2026-03-25T23:00:00.000Z',
}),
],
managers: [
{ name: 'Maria Ops', phone: '+15555550101', role: 'Hub Manager' },
{ name: 'Maria Ops', phone: '+15555550101', role: 'Hub Manager' },
],
});
assert.equal(result.orderId, '11111111-1111-4111-8111-111111111111');
assert.equal(result.status, 'OPEN');
assert.equal(result.schedule.totalShifts, 2);
assert.deepEqual(result.schedule.daysOfWeek, ['MON', 'WED']);
assert.equal(result.staffing.requiredWorkerCount, 4);
assert.equal(result.staffing.filledCount, 2);
assert.equal(result.pay.hourlyRate, '$23.50');
assert.equal(result.managers.length, 1);
assert.equal(result.eligibility.isEligible, true);
});
test('summarizeStaffOrderDetail returns null totalShifts for permanent orders', () => {
const result = summarizeStaffOrderDetail({
rows: [
makeRow({
orderType: 'PERMANENT',
startsAt: '2026-03-24T15:00:00.000Z',
}),
],
});
assert.equal(result.orderType, 'PERMANENT');
assert.equal(result.schedule.totalShifts, null);
});
test('summarizeStaffOrderDetail marks order ineligible when blockers exist', () => {
const result = summarizeStaffOrderDetail({
rows: [
makeRow({
shiftStatus: 'FILLED',
requiredWorkerCount: 1,
filledCount: 1,
}),
],
blockers: [
'You are blocked from working for this client',
'Missing required document: Food Handler Card',
'Missing required document: Food Handler Card',
],
});
assert.equal(result.status, 'FILLED');
assert.equal(result.eligibility.isEligible, false);
assert.deepEqual(result.eligibility.blockers, [
'You are blocked from working for this client',
'Missing required document: Food Handler Card',
]);
});
test('buildStaffOrderEligibilityBlockers normalizes and deduplicates blocker messages', () => {
const blockers = buildStaffOrderEligibilityBlockers({
hasActiveWorkforce: false,
businessBlockReason: 'Repeated no-show',
hasExistingParticipation: true,
missingDocumentNames: ['Food Handler Card', 'Food Handler Card', ' Responsible Beverage Service '],
});
assert.deepEqual(blockers, [
'Workforce profile is not active',
'You are blocked from working for this client: Repeated no-show',
'You already applied to or booked this order',
'Missing required document: Food Handler Card',
'Missing required document: Responsible Beverage Service',
]);
});