Merge pull request #669 from Oloodi/codex/feat-staff-order-detail-v2

feat(api): add staff order detail and compliance eligibility
This commit is contained in:
Achintha Isuru
2026-03-19 16:20:16 -04:00
committed by GitHub
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',
]);
});

View File

@@ -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://'),

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

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',
]);
});

View File

@@ -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,
});

View File

@@ -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:

View File

@@ -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`

View File

@@ -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`

View File

@@ -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

View File

@@ -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