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

@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import { AppError } from '../lib/errors.js';
import { buildStaffOrderEligibilityBlockers, dedupeDocumentNames } from '../lib/staff-order-eligibility.js';
import { query, withTransaction } from './db.js';
import { loadActorContext, requireClientContext, requireStaffContext } from './actor-context.js';
import { recordGeofenceIncident } from './attendance-monitoring.js';
@@ -89,6 +90,53 @@ async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, s
}
}
async function loadMissingRequiredDocuments(client, { tenantId, roleCode, staffId }) {
if (!roleCode) return [];
const result = await client.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
`,
[tenantId, roleCode, staffId]
);
return dedupeDocumentNames(result.rows.map((row) => row.name));
}
function buildMissingDocumentErrorDetails({
roleCode,
orderId = null,
shiftId = null,
roleId = null,
missingDocumentNames = [],
}) {
const blockers = buildStaffOrderEligibilityBlockers({
missingDocumentNames,
});
return {
orderId,
shiftId,
roleId,
roleCode: roleCode || null,
blockers,
missingDocuments: dedupeDocumentNames(missingDocumentNames),
};
}
function buildAssignmentReferencePayload(assignment) {
return {
assignmentId: assignment.id,
@@ -3024,6 +3072,20 @@ export async function bookOrder(actor, payload) {
staffId: staff.id,
});
const missingRequiredDocuments = await loadMissingRequiredDocuments(client, {
tenantId: context.tenant.tenantId,
roleCode: selectedRole.code,
staffId: staff.id,
});
if (missingRequiredDocuments.length > 0) {
throw new AppError('UNPROCESSABLE_ENTITY', 'Staff is missing required documents for this role', 422, buildMissingDocumentErrorDetails({
orderId: payload.orderId,
roleId: payload.roleId,
roleCode: selectedRole.code,
missingDocumentNames: missingRequiredDocuments,
}));
}
const bookingId = crypto.randomUUID();
const assignedShifts = [];

View File

@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildStaffOrderEligibilityBlockers } from '../src/lib/staff-order-eligibility.js';
test('buildStaffOrderEligibilityBlockers formats missing document blockers for command flows', () => {
const blockers = buildStaffOrderEligibilityBlockers({
missingDocumentNames: ['Food Handler Card', 'Food Handler Card', ' Responsible Beverage Service '],
});
assert.deepEqual(blockers, [
'Missing required document: Food Handler Card',
'Missing required document: Responsible Beverage Service',
]);
});