From d2bcb9f3ba8962873dd464e6948f81556a82119b Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:17:48 +0100 Subject: [PATCH] feat(api): add staff order detail and compliance eligibility --- .../src/lib/staff-order-eligibility.js | 39 +++ .../src/services/mobile-command-service.js | 62 ++++ .../test/staff-order-eligibility.test.js | 14 + .../src/contracts/core/create-verification.js | 2 +- .../core-api/src/services/mobile-upload.js | 80 +++++ .../src/services/verification-jobs.js | 99 +++++- backend/core-api/test/app.test.js | 22 ++ .../src/lib/staff-order-eligibility.js | 39 +++ backend/query-api/src/routes/mobile.js | 22 ++ .../src/services/mobile-query-service.js | 312 ++++++++++++++++++ backend/query-api/test/mobile-routes.test.js | 22 ++ .../query-api/test/staff-order-detail.test.js | 117 +++++++ .../scripts/live-smoke-v2-unified.mjs | 202 ++++++++++-- docs/BACKEND/API_GUIDES/V2/README.md | 1 + .../API_GUIDES/V2/mobile-coding-agent-spec.md | 8 +- .../V2/mobile-frontend-implementation-spec.md | 6 +- docs/BACKEND/API_GUIDES/V2/staff-shifts.md | 40 ++- docs/BACKEND/API_GUIDES/V2/unified-api.md | 6 +- 18 files changed, 1051 insertions(+), 42 deletions(-) create mode 100644 backend/command-api/src/lib/staff-order-eligibility.js create mode 100644 backend/command-api/test/staff-order-eligibility.test.js create mode 100644 backend/query-api/src/lib/staff-order-eligibility.js create mode 100644 backend/query-api/test/staff-order-detail.test.js diff --git a/backend/command-api/src/lib/staff-order-eligibility.js b/backend/command-api/src/lib/staff-order-eligibility.js new file mode 100644 index 00000000..a8f63897 --- /dev/null +++ b/backend/command-api/src/lib/staff-order-eligibility.js @@ -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); +} diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index def1d189..0448733b 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -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 = []; diff --git a/backend/command-api/test/staff-order-eligibility.test.js b/backend/command-api/test/staff-order-eligibility.test.js new file mode 100644 index 00000000..245b8c71 --- /dev/null +++ b/backend/command-api/test/staff-order-eligibility.test.js @@ -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', + ]); +}); diff --git a/backend/core-api/src/contracts/core/create-verification.js b/backend/core-api/src/contracts/core/create-verification.js index ee03d8ec..970b016e 100644 --- a/backend/core-api/src/contracts/core/create-verification.js +++ b/backend/core-api/src/contracts/core/create-verification.js @@ -1,7 +1,7 @@ import { z } from 'zod'; export const createVerificationSchema = z.object({ - type: z.enum(['attire', 'government_id', 'certification']), + type: z.enum(['attire', 'government_id', 'certification', 'tax_form']), subjectType: z.string().min(1).max(80).optional(), subjectId: z.string().min(1).max(120).optional(), fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'), diff --git a/backend/core-api/src/services/mobile-upload.js b/backend/core-api/src/services/mobile-upload.js index 07ad0420..e386bc6d 100644 --- a/backend/core-api/src/services/mobile-upload.js +++ b/backend/core-api/src/services/mobile-upload.js @@ -87,6 +87,70 @@ async function resolveVerificationBackedUpload({ }; } +async function bindVerificationToStaffDocument(client, { + verificationId, + tenantId, + staffId, + document, + routeType, +}) { + await client.query( + ` + UPDATE verification_jobs + SET staff_id = $2, + document_id = $3, + subject_type = $4, + subject_id = $5, + metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + staffId, + document.id, + routeType === 'attire' ? 'attire_item' : 'staff_document', + document.id, + JSON.stringify({ + routeType, + documentType: document.document_type, + boundFromFinalize: true, + }), + ] + ); +} + +async function bindVerificationToCertificate(client, { + verificationId, + staffId, + certificateType, + certificateName, + certificateIssuer, +}) { + await client.query( + ` + UPDATE verification_jobs + SET staff_id = $2, + subject_type = 'certificate', + subject_id = $3, + metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + staffId, + certificateType, + JSON.stringify({ + certificateType, + name: certificateName || null, + issuer: certificateIssuer || null, + boundFromFinalize: true, + }), + ] + ); +} + export async function uploadProfilePhoto({ actorUid, file }) { const context = await requireStaffContext(actorUid); const uploaded = await uploadActorFile({ @@ -166,6 +230,14 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp }); await withTransaction(async (client) => { + await bindVerificationToStaffDocument(client, { + verificationId: finalized.verification.verificationId, + tenantId: context.tenant.tenantId, + staffId: context.staff.staffId, + document, + routeType, + }); + await client.query( ` INSERT INTO staff_documents ( @@ -363,6 +435,14 @@ export async function finalizeCertificateUpload({ actorUid, payload }) { }); const certificateResult = await withTransaction(async (client) => { + await bindVerificationToCertificate(client, { + verificationId: finalized.verification.verificationId, + staffId: context.staff.staffId, + certificateType: payload.certificateType, + certificateName: payload.name, + certificateIssuer: payload.issuer, + }); + const existing = await client.query( ` SELECT id diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js index ac70aab8..61265295 100644 --- a/backend/core-api/src/services/verification-jobs.js +++ b/backend/core-api/src/services/verification-jobs.js @@ -225,6 +225,78 @@ async function appendVerificationEvent(client, { ); } +function normalizeArtifactStatus(status) { + switch (`${status || ''}`.toUpperCase()) { + case VerificationStatus.AUTO_PASS: + case VerificationStatus.APPROVED: + return 'VERIFIED'; + case VerificationStatus.AUTO_FAIL: + case VerificationStatus.REJECTED: + return 'REJECTED'; + case VerificationStatus.PENDING: + case VerificationStatus.PROCESSING: + case VerificationStatus.NEEDS_REVIEW: + case VerificationStatus.ERROR: + default: + return 'PENDING'; + } +} + +function looksLikeUuid(value) { + return /^[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 || ''}`); +} + +async function syncVerificationSubjectStatus(client, job) { + const subjectType = job.subject_type || job.subjectType || null; + const subjectId = job.subject_id || job.subjectId || null; + const tenantId = job.tenant_id || job.tenantId || null; + const staffId = job.staff_id || job.staffId || null; + const verificationId = job.id || job.verificationId || null; + + if (!subjectType || !subjectId || !tenantId || !staffId || !verificationId) { + return; + } + + const nextStatus = normalizeArtifactStatus(job.status); + const metadataPatch = JSON.stringify({ + verificationStatus: job.status, + verificationJobId: verificationId, + syncedFromVerification: true, + }); + const subjectIdIsUuid = looksLikeUuid(subjectId); + + if (subjectType === 'staff_document' || subjectType === 'attire_item' || (subjectType === 'worker' && subjectIdIsUuid)) { + await client.query( + ` + UPDATE staff_documents + SET status = $4, + metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, + updated_at = NOW() + WHERE tenant_id = $1 + AND staff_id = $2 + AND document_id::text = $3 + `, + [tenantId, staffId, subjectId, nextStatus, metadataPatch] + ); + return; + } + + if (subjectType === 'certificate' || (subjectType === 'worker' && !subjectIdIsUuid)) { + await client.query( + ` + UPDATE certificates + SET status = $4, + metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, + updated_at = NOW() + WHERE tenant_id = $1 + AND staff_id = $2 + AND certificate_type = $3 + `, + [tenantId, staffId, subjectId, nextStatus, metadataPatch] + ); + } +} + async function runAttireChecks(job) { if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') { return { @@ -324,6 +396,13 @@ function getProviderConfig(type) { token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN, }; } + if (type === 'tax_form') { + return { + name: 'tax-form-provider', + url: process.env.VERIFICATION_TAX_FORM_PROVIDER_URL, + token: process.env.VERIFICATION_TAX_FORM_PROVIDER_TOKEN, + }; + } return { name: 'certification-provider', url: process.env.VERIFICATION_CERT_PROVIDER_URL, @@ -458,7 +537,7 @@ async function processVerificationJob(verificationId) { : await runThirdPartyChecks(startedJob, startedJob.type); await withTransaction(async (client) => { - await client.query( + const updated = await client.query( ` UPDATE verification_jobs SET status = $2, @@ -469,6 +548,7 @@ async function processVerificationJob(verificationId) { provider_reference = $7, updated_at = NOW() WHERE id = $1 + RETURNING * `, [ verificationId, @@ -481,6 +561,8 @@ async function processVerificationJob(verificationId) { ] ); + await syncVerificationSubjectStatus(client, updated.rows[0]); + await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: VerificationStatus.PROCESSING, @@ -494,7 +576,7 @@ async function processVerificationJob(verificationId) { }); } catch (error) { await withTransaction(async (client) => { - await client.query( + const updated = await client.query( ` UPDATE verification_jobs SET status = $2, @@ -503,6 +585,7 @@ async function processVerificationJob(verificationId) { provider_reference = $4, updated_at = NOW() WHERE id = $1 + RETURNING * `, [ verificationId, @@ -512,6 +595,8 @@ async function processVerificationJob(verificationId) { ] ); + await syncVerificationSubjectStatus(client, updated.rows[0]); + await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: VerificationStatus.PROCESSING, @@ -703,17 +788,20 @@ export async function reviewVerificationJob(verificationId, actorUid, review) { reasonCode: review.reasonCode || 'MANUAL_REVIEW', }; - await client.query( + const updatedResult = await client.query( ` UPDATE verification_jobs SET status = $2, review = $3::jsonb, updated_at = NOW() WHERE id = $1 + RETURNING * `, [verificationId, review.decision, JSON.stringify(reviewPayload)] ); + await syncVerificationSubjectStatus(client, updatedResult.rows[0]); + await client.query( ` INSERT INTO verification_reviews ( @@ -800,7 +888,7 @@ export async function retryVerificationJob(verificationId, actorUid) { }); } - await client.query( + const updatedResult = await client.query( ` UPDATE verification_jobs SET status = $2, @@ -812,10 +900,13 @@ export async function retryVerificationJob(verificationId, actorUid) { review = '{}'::jsonb, updated_at = NOW() WHERE id = $1 + RETURNING * `, [verificationId, VerificationStatus.PENDING] ); + await syncVerificationSubjectStatus(client, updatedResult.rows[0]); + await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: job.status, diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index fafed45b..646ac494 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -349,6 +349,28 @@ test('POST /core/verifications creates async job and GET returns status', async assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status)); }); +test('POST /core/verifications accepts tax_form verification jobs', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'tax_form', + subjectType: 'worker', + subjectId: 'document-tax-i9', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/i9.pdf', + rules: { formType: 'I-9' }, + }); + + assert.equal(created.status, 202); + assert.equal(created.body.type, 'tax_form'); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + assert.equal(status.body.type, 'tax_form'); + assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status)); +}); + test('POST /core/verifications rejects file paths not owned by actor', async () => { const app = createApp(); const res = await request(app) diff --git a/backend/query-api/src/lib/staff-order-eligibility.js b/backend/query-api/src/lib/staff-order-eligibility.js new file mode 100644 index 00000000..a8f63897 --- /dev/null +++ b/backend/query-api/src/lib/staff-order-eligibility.js @@ -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); +} diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 31bbd090..a565c3e3 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -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); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index 9ed9ae15..db22a212 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -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( diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index 428163de..997e91e7 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -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) diff --git a/backend/query-api/test/staff-order-detail.test.js b/backend/query-api/test/staff-order-detail.test.js new file mode 100644 index 00000000..b49b8b94 --- /dev/null +++ b/backend/query-api/test/staff-order-detail.test.js @@ -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', + ]); +}); diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index f67e2503..930633e8 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -160,6 +160,22 @@ async function finalizeVerifiedUpload({ }; } +async function approveVerification({ + token, + verificationId, + note = 'Smoke approval', +}) { + return apiCall(`/verifications/${verificationId}/review`, { + method: 'POST', + token, + body: { + decision: 'APPROVED', + note, + reasonCode: 'SMOKE_APPROVAL', + }, + }); +} + async function signInClient() { return apiCall('/auth/client/sign-in', { method: 'POST', @@ -794,6 +810,8 @@ async function main() { assert.equal(typeof assignedTodayShift.longitude, 'number'); assert.equal(assignedTodayShift.clockInMode, fixture.shifts.assigned.clockInMode); assert.equal(assignedTodayShift.allowClockInOverride, fixture.shifts.assigned.allowClockInOverride); + const clockableTodayShift = todaysShifts.items.find((shift) => shift.attendanceStatus === 'NOT_CLOCKED_IN') + || assignedTodayShift; logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length }); const attendanceStatusBefore = await apiCall('/staff/clock-in/status', { @@ -827,30 +845,61 @@ async function main() { const availableOrders = await apiCall('/staff/orders/available?limit=20', { token: staffAuth.idToken, }); - const availableOrder = availableOrders.items.find((item) => item.orderId === createdRecurringOrder.orderId) - || availableOrders.items[0]; - assert.ok(availableOrder); - assert.ok(availableOrder.roleId); - logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: availableOrder.orderId }); + assert.ok(availableOrders.items.length > 0); - const bookedOrder = await apiCall(`/staff/orders/${availableOrder.orderId}/book`, { - method: 'POST', + let ineligibleOrder = null; + let ineligibleOrderDetail = null; + for (const item of availableOrders.items) { + const detail = await apiCall(`/staff/orders/${item.orderId}`, { + token: staffAuth.idToken, + }); + + if (!ineligibleOrderDetail && detail.eligibility?.isEligible === false) { + ineligibleOrder = item; + ineligibleOrderDetail = detail; + break; + } + } + + const orderCard = ineligibleOrder || availableOrders.items[0]; + const orderDetail = ineligibleOrderDetail || await apiCall(`/staff/orders/${orderCard.orderId}`, { token: staffAuth.idToken, - idempotencyKey: uniqueKey('staff-order-book'), - body: { - roleId: availableOrder.roleId, - }, }); - assert.equal(bookedOrder.orderId, availableOrder.orderId); - assert.ok(bookedOrder.assignedShiftCount >= 1); - assert.equal(bookedOrder.status, 'PENDING'); - assert.ok(Array.isArray(bookedOrder.assignedShifts)); - logStep('staff.orders.book.ok', { - orderId: bookedOrder.orderId, - assignedShiftCount: bookedOrder.assignedShiftCount, - status: bookedOrder.status, + assert.ok(orderCard.roleId); + logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: orderCard.orderId }); + + assert.equal(orderDetail.orderId, orderCard.orderId); + assert.equal(orderDetail.roleId, orderCard.roleId); + assert.ok(orderDetail.clientName); + assert.ok(orderDetail.schedule); + assert.ok(orderDetail.location); + assert.ok(Array.isArray(orderDetail.managers)); + assert.ok(orderDetail.eligibility); + logStep('staff.orders.detail.ok', { + orderId: orderDetail.orderId, + status: orderDetail.status, + isEligible: orderDetail.eligibility.isEligible, }); + if (orderDetail.eligibility?.isEligible === false) { + const rejectedIneligibleBooking = await apiCall(`/staff/orders/${orderCard.orderId}/book`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-order-book-ineligible'), + body: { + roleId: orderDetail.roleId, + }, + allowFailure: true, + }); + assert.equal(rejectedIneligibleBooking.statusCode, 422); + assert.equal(rejectedIneligibleBooking.body.code, 'UNPROCESSABLE_ENTITY'); + assert.ok(Array.isArray(rejectedIneligibleBooking.body.details?.blockers)); + logStep('staff.orders.book.ineligible.rejected.ok', { + orderId: orderCard.orderId, + blockers: rejectedIneligibleBooking.body.details.blockers.length, + }); + } + const openShifts = await apiCall('/staff/shifts/open', { token: staffAuth.idToken, }); @@ -864,10 +913,7 @@ async function main() { const pendingShifts = await apiCall('/staff/shifts/pending', { token: staffAuth.idToken, }); - assert.ok( - bookedOrder.assignedShifts.some((shift) => pendingShifts.items.some((item) => item.shiftId === shift.shiftId)) - ); - const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id) + const pendingShift = pendingShifts.items.find((item) => item.shiftId === openShift.shiftId) || pendingShifts.items[0]; assert.ok(pendingShift); logStep('staff.shifts.pending.ok', { count: pendingShifts.items.length }); @@ -1146,12 +1192,12 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-apply'), body: { - roleId: fixture.shiftRoles.availableBarista.id, + roleId: openShift.roleId, }, }); logStep('staff.shifts.apply.ok', appliedShift); - const acceptedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/accept`, { + const acceptedShift = await apiCall(`/staff/shifts/${pendingShift.shiftId}/accept`, { method: 'POST', token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-accept'), @@ -1164,7 +1210,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-clock-in'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', latitude: fixture.clockPoint.latitude + 0.0075, @@ -1177,7 +1223,7 @@ async function main() { }, }); assert.equal(clockIn.validationStatus, 'FLAGGED'); - assert.equal(clockIn.effectiveClockInMode, fixture.shifts.assigned.clockInMode); + assert.equal(clockIn.effectiveClockInMode, clockableTodayShift.clockInMode); assert.equal(clockIn.overrideUsed, true); assert.ok(clockIn.securityProofId); logStep('staff.clock-in.ok', clockIn); @@ -1187,7 +1233,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-clock-in-duplicate'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', latitude: fixture.clockPoint.latitude, @@ -1214,7 +1260,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-location-stream'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', points: [ @@ -1268,7 +1314,7 @@ async function main() { token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-clock-out'), body: { - shiftId: fixture.shifts.assigned.id, + shiftId: clockableTodayShift.shiftId, sourceType: 'GEO', deviceId: 'smoke-iphone-15-pro', latitude: fixture.clockPoint.latitude, @@ -1283,7 +1329,7 @@ async function main() { assert.ok(clockOut.securityProofId); logStep('staff.clock-out.ok', clockOut); - const submittedCompletedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/submit-for-approval`, { + const submittedCompletedShift = await apiCall(`/staff/shifts/${clockableTodayShift.shiftId}/submit-for-approval`, { method: 'POST', token: staffAuth.idToken, idempotencyKey: uniqueKey('staff-shift-submit-approval'), @@ -1430,6 +1476,50 @@ async function main() { assert.equal(uploadedGovId.finalized.documentId, fixture.documents.governmentId.id); logStep('staff.profile.document.upload.ok', uploadedGovId.finalized); + if (!['APPROVED', 'AUTO_PASS'].includes(`${uploadedGovId.finalized.verification?.status || ''}`)) { + const reviewedGovId = await approveVerification({ + token: ownerSession.sessionToken, + verificationId: uploadedGovId.finalized.verification.verificationId, + note: 'Smoke approval for government ID', + }); + assert.equal(reviewedGovId.status, 'APPROVED'); + logStep('staff.profile.document.review.ok', { + verificationId: reviewedGovId.verificationId, + status: reviewedGovId.status, + }); + } + + const uploadedI9 = await finalizeVerifiedUpload({ + token: staffAuth.idToken, + uploadCategory: 'staff-tax-form', + filename: 'i9-completed.pdf', + contentType: 'application/pdf', + content: Buffer.from('fake-i9-tax-form'), + finalizePath: `/staff/profile/documents/${fixture.documents.taxFormI9.id}/upload`, + finalizeMethod: 'PUT', + verificationType: 'tax_form', + subjectId: fixture.documents.taxFormI9.id, + rules: { + documentId: fixture.documents.taxFormI9.id, + formType: 'I-9', + }, + }); + assert.equal(uploadedI9.finalized.documentId, fixture.documents.taxFormI9.id); + logStep('staff.profile.tax-form.upload.ok', uploadedI9.finalized); + + if (!['APPROVED', 'AUTO_PASS'].includes(`${uploadedI9.finalized.verification?.status || ''}`)) { + const reviewedI9 = await approveVerification({ + token: ownerSession.sessionToken, + verificationId: uploadedI9.finalized.verification.verificationId, + note: 'Smoke approval for completed I-9', + }); + assert.equal(reviewedI9.status, 'APPROVED'); + logStep('staff.profile.tax-form.review.ok', { + verificationId: reviewedI9.verificationId, + status: reviewedI9.status, + }); + } + const uploadedAttire = await finalizeVerifiedUpload({ token: staffAuth.idToken, uploadCategory: 'staff-attire', @@ -1474,9 +1564,57 @@ async function main() { const profileDocumentsAfter = await apiCall('/staff/profile/documents', { token: staffAuth.idToken, }); - assert.ok(profileDocumentsAfter.items.some((item) => item.documentId === fixture.documents.governmentId.id)); + const governmentIdAfter = profileDocumentsAfter.items.find((item) => item.documentId === fixture.documents.governmentId.id); + assert.ok(governmentIdAfter); + assert.equal(governmentIdAfter.status, 'VERIFIED'); logStep('staff.profile.documents-after.ok', { count: profileDocumentsAfter.items.length }); + const availableOrdersAfterVerification = await apiCall('/staff/orders/available?limit=20', { + token: staffAuth.idToken, + }); + let eligibleOrder = null; + let eligibleOrderDetail = null; + for (const item of availableOrdersAfterVerification.items) { + const detail = await apiCall(`/staff/orders/${item.orderId}`, { + token: staffAuth.idToken, + }); + if (detail.eligibility?.isEligible === true) { + eligibleOrder = item; + eligibleOrderDetail = detail; + break; + } + } + assert.ok(eligibleOrder, 'Expected at least one eligible available order after document verification'); + + const bookedOrder = await apiCall(`/staff/orders/${eligibleOrder.orderId}/book`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-order-book'), + body: { + roleId: eligibleOrderDetail.roleId, + }, + }); + assert.equal(bookedOrder.orderId, eligibleOrder.orderId); + assert.ok(bookedOrder.assignedShiftCount >= 1); + assert.equal(bookedOrder.status, 'PENDING'); + assert.ok(Array.isArray(bookedOrder.assignedShifts)); + logStep('staff.orders.book.ok', { + orderId: bookedOrder.orderId, + assignedShiftCount: bookedOrder.assignedShiftCount, + status: bookedOrder.status, + }); + + const pendingShiftsAfterBooking = await apiCall('/staff/shifts/pending', { + token: staffAuth.idToken, + }); + assert.ok( + bookedOrder.assignedShifts.some((shift) => pendingShiftsAfterBooking.items.some((item) => item.shiftId === shift.shiftId)) + ); + logStep('staff.shifts.pending-after-order-book.ok', { + count: pendingShiftsAfterBooking.items.length, + bookedShiftCount: bookedOrder.assignedShiftCount, + }); + const certificatesAfter = await apiCall('/staff/profile/certificates', { token: staffAuth.idToken, }); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 4d8eb6d6..3a800207 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -120,6 +120,7 @@ For geofence-heavy staff flows, frontend should read the policy from: - `GET /staff/clock-in/shifts/today` - `GET /staff/shifts/:shiftId` +- `GET /staff/orders/:orderId` - `GET /client/hubs` Important operational rules: diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md index a7a2f940..1a87ac9d 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -23,7 +23,7 @@ Supporting docs: - Send `Idempotency-Key` on every write route. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. -- For staff order booking, `roleId` must come from the response of `GET /staff/orders/available`. +- For staff order booking, `roleId` must come from the response of `GET /staff/orders/:orderId`. - Treat API timestamp fields as UTC and convert them to local time in the app. ## 2) What is implemented now @@ -235,14 +235,17 @@ Important: ### Find shifts - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- use `roleId` from the order-available response when booking an order +- use `GET /staff/orders/:orderId` as the source of truth for the order details page +- use `roleId` from the order-detail response when booking an order - that `roleId` is the role catalog id for the grouped order booking flow +- if order booking returns `422`, render `details.blockers` and keep the worker on the order details page - use `roleId` from the open-shifts response only for shift-level apply - that `roleId` is the concrete `shift_roles.id` @@ -260,6 +263,7 @@ Rule: Staff shift detail and list rules: +- `GET /staff/orders/:orderId` returns the worker booking detail contract with `schedule`, `location`, `pay`, `staffing`, `managers`, and `eligibility` - assigned shifts include `clientName`, `hourlyRate`, `totalRate`, `startTime`, `endTime` - shift detail includes `clientName`, `latitude`, `longitude`, `hourlyRate`, `totalRate` - completed shifts include `date`, `clientName`, `startTime`, `endTime`, `hourlyRate`, `totalRate` diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md index a4847d66..1d5c41ca 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -32,6 +32,7 @@ Important consequences: - `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response. - `GET /staff/orders/available` returns grouped order opportunities for atomic booking. - `POST /staff/orders/:orderId/book` must send the `roleId` from that response. +- if order booking returns `422`, use `details.blockers` to explain why the worker is not eligible - `GET /client/shifts/scheduled` is the canonical timeline/read model for the client app. - `GET /client/orders/view` is a deprecated compatibility alias. - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts. @@ -180,14 +181,17 @@ Rapid-order flow: ### Find shifts - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- send the `roleId` from the order-available response when booking an order +- use `GET /staff/orders/:orderId` as the source of truth for the order details page +- send the `roleId` from the order-detail response when booking an order - this `roleId` is the role catalog id for grouped order booking +- if booking fails with `422`, render `details.blockers` and keep the worker on the review screen - send the `roleId` from the open-shifts response only when applying to one shift - that route still uses the concrete `shift_roles.id` diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md index d881449d..742039f7 100644 --- a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -9,6 +9,7 @@ Base URL: ## Read routes - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -80,6 +81,7 @@ Example response: - booking is atomic across the future shifts in that order for the selected role - backend returns `PENDING` when the booking is reserved but not instant-booked - backend returns `CONFIRMED` when every future shift in that booking path is instant-booked +- backend returns `422 UNPROCESSABLE_ENTITY` when the worker is not eligible to book that order Example request: @@ -91,8 +93,44 @@ Example request: Important: -- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/available` +- `GET /staff/orders/:orderId` is now the source of truth for the order detail screen before booking +- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/:orderId` - it is not the same thing as the per-shift `shift_roles.id` +- when booking is rejected, use `details.blockers` from the error response to explain why + +### Order detail + +`GET /staff/orders/:orderId` + +Use this as the source of truth for the worker order-review page before calling `POST /staff/orders/:orderId/book`. + +Response shape includes: + +- `orderId` +- `orderType` +- `roleId` +- `roleCode` +- `roleName` +- `clientName` +- `businessId` +- `instantBook` +- `dispatchTeam` +- `dispatchPriority` +- `jobDescription` +- `instructions` +- `status` +- `schedule` +- `location` +- `pay` +- `staffing` +- `managers` +- `eligibility` + +Frontend rules: + +- call this endpoint after a worker taps an order card from `GET /staff/orders/available` +- use the returned `roleId` when calling `POST /staff/orders/:orderId/book` +- if `eligibility.isEligible` is `false`, show the blocker messages and disable booking ### Find shifts diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index dcbf40c7..dba066f5 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -184,6 +184,7 @@ The manager is created as an invited business membership. If `hubId` is present, - `GET /staff/payments/history` - `GET /staff/payments/chart` - `GET /staff/orders/available` +- `GET /staff/orders/:orderId` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -250,9 +251,12 @@ Example `GET /staff/profile/stats` response: Order booking route notes: - `GET /staff/orders/available` is the canonical order-level marketplace feed for recurring and grouped work +- `GET /staff/orders/:orderId` is the canonical staff order-detail route before booking - `GET /staff/shifts/open` remains available for shift-level opportunities and swap coverage - `POST /staff/orders/:orderId/book` books the future shifts of an order atomically for one role -- the `roleId` returned by `GET /staff/orders/available` is the role catalog id for the order booking flow +- if booking is rejected for eligibility reasons, backend returns `422 UNPROCESSABLE_ENTITY` with `details.blockers` +- use the `roleId` returned by `GET /staff/orders/:orderId` when booking +- that `roleId` is the role catalog id for the order booking flow - the `roleId` returned by `GET /staff/shifts/open` is still the concrete `shift_roles.id` for shift-level apply ### Staff writes