Merge branch 'dev' into 595-sred-research-implement-cross-platform-nfc-clocking-interface-fe
This commit is contained in:
39
backend/command-api/src/lib/staff-order-eligibility.js
Normal file
39
backend/command-api/src/lib/staff-order-eligibility.js
Normal 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);
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
14
backend/command-api/test/staff-order-eligibility.test.js
Normal file
14
backend/command-api/test/staff-order-eligibility.test.js
Normal 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',
|
||||
]);
|
||||
});
|
||||
@@ -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://'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
39
backend/query-api/src/lib/staff-order-eligibility.js
Normal file
39
backend/query-api/src/lib/staff-order-eligibility.js
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
117
backend/query-api/test/staff-order-detail.test.js
Normal file
117
backend/query-api/test/staff-order-detail.test.js
Normal 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',
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user