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(