fix(api): close v2 mobile contract gaps

This commit is contained in:
zouantchaw
2026-03-17 22:37:45 +01:00
parent afcd896b47
commit 008dd7efb1
14 changed files with 1315 additions and 54 deletions

View File

@@ -196,6 +196,11 @@ export const shiftDecisionSchema = z.object({
reason: z.string().max(1000).optional(),
});
export const shiftSubmitApprovalSchema = z.object({
shiftId: z.string().uuid(),
note: z.string().max(2000).optional(),
});
export const staffClockInSchema = z.object({
assignmentId: z.string().uuid().optional(),
shiftId: z.string().uuid().optional(),

View File

@@ -28,6 +28,7 @@ import {
setupStaffProfile,
staffClockIn,
staffClockOut,
submitCompletedShiftForApproval,
submitLocationStreamBatch,
submitTaxForm,
unregisterClientPushToken,
@@ -70,6 +71,7 @@ import {
pushTokenRegisterSchema,
shiftApplySchema,
shiftDecisionSchema,
shiftSubmitApprovalSchema,
staffClockInSchema,
staffClockOutSchema,
staffLocationBatchSchema,
@@ -104,6 +106,7 @@ const defaultHandlers = {
setupStaffProfile,
staffClockIn,
staffClockOut,
submitCompletedShiftForApproval,
submitLocationStreamBatch,
submitTaxForm,
unregisterClientPushToken,
@@ -402,6 +405,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/submit-for-approval', {
schema: shiftSubmitApprovalSchema,
policyAction: 'staff.shifts.submit',
resource: 'shift',
handler: handlers.submitCompletedShiftForApproval,
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.put(...mobileCommand('/staff/profile/personal-info', {
schema: personalInfoUpdateSchema,
policyAction: 'staff.profile.write',

View File

@@ -8,12 +8,14 @@ import { uploadLocationBatch } from './location-log-storage.js';
import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js';
import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js';
import {
cancelOrder as cancelOrderCommand,
clockIn as clockInCommand,
clockOut as clockOutCommand,
createOrder as createOrderCommand,
} from './command-service.js';
const MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED'];
const MOBILE_CANCELLABLE_APPLICATION_STATUSES = ['PENDING', 'CONFIRMED'];
function toIsoOrNull(value) {
return value ? new Date(value).toISOString() : null;
}
@@ -397,18 +399,153 @@ async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId
WHERE o.id = $1
AND o.tenant_id = $2
AND o.business_id = $3
AND s.starts_at > NOW()
AND s.status NOT IN ('CANCELLED', 'COMPLETED')
GROUP BY o.id
`,
[orderId, tenantId, businessId]
);
if (result.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found for edit flow', 404, { orderId });
throw new AppError('ORDER_EDIT_BLOCKED', 'Order has no future shifts available for edit', 409, { orderId });
}
return result.rows[0];
}
async function cancelFutureOrderSlice(client, {
actorUid,
tenantId,
businessId,
orderId,
reason,
metadata = {},
}) {
const orderResult = await client.query(
`
SELECT id, order_number, status
FROM orders
WHERE id = $1
AND tenant_id = $2
AND business_id = $3
FOR UPDATE
`,
[orderId, tenantId, businessId]
);
if (orderResult.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found for cancel flow', 404, { orderId });
}
const order = orderResult.rows[0];
const futureShiftsResult = await client.query(
`
SELECT id
FROM shifts
WHERE order_id = $1
AND starts_at > NOW()
AND status NOT IN ('CANCELLED', 'COMPLETED')
ORDER BY starts_at ASC
FOR UPDATE
`,
[order.id]
);
if (futureShiftsResult.rowCount === 0) {
return {
orderId: order.id,
orderNumber: order.order_number,
status: order.status,
futureOnly: true,
cancelledShiftCount: 0,
alreadyCancelled: true,
};
}
const shiftIds = futureShiftsResult.rows.map((row) => row.id);
await client.query(
`
UPDATE orders
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[
order.id,
JSON.stringify({
futureCancellationReason: reason || null,
futureCancellationBy: actorUid,
futureCancellationAt: new Date().toISOString(),
...metadata,
}),
]
);
await client.query(
`
UPDATE shifts
SET status = 'CANCELLED',
updated_at = NOW()
WHERE id = ANY($1::uuid[])
`,
[shiftIds]
);
await client.query(
`
UPDATE assignments
SET status = 'CANCELLED',
updated_at = NOW()
WHERE shift_id = ANY($1::uuid[])
AND status = ANY($2::text[])
`,
[shiftIds, MOBILE_CANCELLABLE_ASSIGNMENT_STATUSES]
);
await client.query(
`
UPDATE applications
SET status = 'CANCELLED',
updated_at = NOW()
WHERE shift_id = ANY($1::uuid[])
AND status = ANY($2::text[])
`,
[shiftIds, MOBILE_CANCELLABLE_APPLICATION_STATUSES]
);
for (const shiftId of shiftIds) {
const roleIds = await client.query(
'SELECT id FROM shift_roles WHERE shift_id = $1',
[shiftId]
);
for (const role of roleIds.rows) {
await refreshShiftRoleCounts(client, role.id);
}
await refreshShiftCounts(client, shiftId);
}
await insertDomainEvent(client, {
tenantId,
aggregateType: 'order',
aggregateId: order.id,
eventType: 'ORDER_FUTURE_SLICE_CANCELLED',
actorUserId: actorUid,
payload: {
reason: reason || null,
shiftIds,
futureOnly: true,
},
});
return {
orderId: order.id,
orderNumber: order.order_number,
status: 'CANCELLED',
futureOnly: true,
cancelledShiftCount: shiftIds.length,
};
}
async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) {
const context = await requireStaffContext(actorUid);
if (payload.assignmentId) {
@@ -1547,11 +1684,31 @@ export async function createEditedOrderCopy(actor, payload) {
);
const templateShifts = Array.isArray(template.shifts) ? template.shifts : [];
const templatePositions = templateShifts.flatMap((shift) => (Array.isArray(shift.roles) ? shift.roles : []).map((role) => ({
const templatePositions = Array.from(
templateShifts.reduce((deduped, shift) => {
for (const role of (Array.isArray(shift.roles) ? shift.roles : [])) {
const normalized = {
...role,
startTime: role.startTime || shift.startTime,
endTime: role.endTime || shift.endTime,
})));
};
const key = [
normalized.roleId || '',
normalized.roleCode || '',
normalized.roleName || '',
normalized.startTime || '',
normalized.endTime || '',
normalized.workerCount ?? '',
normalized.payRateCents ?? '',
normalized.billRateCents ?? '',
].join('|');
if (!deduped.has(key)) {
deduped.set(key, normalized);
}
}
return deduped;
}, new Map()).values()
);
const firstShift = templateShifts[0] || {};
const lastShift = templateShifts[templateShifts.length - 1] || {};
const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME';
@@ -1593,12 +1750,17 @@ export async function createEditedOrderCopy(actor, payload) {
export async function cancelClientOrder(actor, payload) {
const context = await requireClientContext(actor.uid);
return cancelOrderCommand(actor, {
return withTransaction(async (client) => {
await ensureActorUser(client, actor);
return cancelFutureOrderSlice(client, {
actorUid: actor.uid,
tenantId: context.tenant.tenantId,
businessId: context.business.businessId,
orderId: payload.orderId,
reason: payload.reason,
metadata: payload.metadata,
});
});
}
export async function staffClockIn(actor, payload) {
@@ -2361,6 +2523,68 @@ export async function requestShiftSwap(actor, payload) {
});
}
export async function submitCompletedShiftForApproval(actor, payload) {
const context = await requireStaffContext(actor.uid);
return withTransaction(async (client) => {
await ensureActorUser(client, actor);
const assignment = await requireAnyAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid);
if (!['CHECKED_OUT', 'COMPLETED'].includes(assignment.status)) {
throw new AppError('INVALID_TIMESHEET_STATE', 'Only completed or checked-out shifts can be submitted for approval', 409, {
shiftId: payload.shiftId,
assignmentStatus: assignment.status,
});
}
const timesheetResult = await client.query(
`
INSERT INTO timesheets (
tenant_id,
assignment_id,
staff_id,
status,
metadata
)
VALUES ($1, $2, $3, 'SUBMITTED', $4::jsonb)
ON CONFLICT (assignment_id) DO UPDATE
SET status = CASE
WHEN timesheets.status IN ('APPROVED', 'PAID') THEN timesheets.status
ELSE 'SUBMITTED'
END,
metadata = COALESCE(timesheets.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
RETURNING id, status, metadata
`,
[
context.tenant.tenantId,
assignment.id,
assignment.staff_id,
JSON.stringify({
submittedAt: new Date().toISOString(),
submittedBy: actor.uid,
submissionNote: payload.note || null,
}),
]
);
await insertDomainEvent(client, {
tenantId: context.tenant.tenantId,
aggregateType: 'timesheet',
aggregateId: timesheetResult.rows[0].id,
eventType: 'TIMESHEET_SUBMITTED_FOR_APPROVAL',
actorUserId: actor.uid,
payload,
});
return {
assignmentId: assignment.id,
shiftId: assignment.shift_id,
timesheetId: timesheetResult.rows[0].id,
status: timesheetResult.rows[0].status,
submitted: timesheetResult.rows[0].status === 'SUBMITTED',
};
});
}
export async function setupStaffProfile(actor, payload) {
return withTransaction(async (client) => {
const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId);

View File

@@ -77,6 +77,12 @@ function createMobileHandlers() {
assignmentId: payload.assignmentId || 'assignment-1',
status: 'CLOCK_OUT',
}),
submitCompletedShiftForApproval: async (_actor, payload) => ({
shiftId: payload.shiftId,
timesheetId: 'timesheet-1',
status: 'SUBMITTED',
submitted: true,
}),
submitLocationStreamBatch: async (_actor, payload) => ({
assignmentId: payload.assignmentId || 'assignment-1',
pointCount: payload.points.length,
@@ -342,3 +348,19 @@ test('POST /commands/staff/profile/bank-accounts uppercases account type', async
assert.equal(res.body.accountType, 'CHECKING');
assert.equal(res.body.last4, '7890');
});
test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/shifts/77777777-7777-4777-8777-777777777777/submit-for-approval')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'shift-submit-approval-1')
.send({
note: 'Worked full shift and ready for approval',
});
assert.equal(res.status, 200);
assert.equal(res.body.shiftId, '77777777-7777-4777-8777-777777777777');
assert.equal(res.body.timesheetId, 'timesheet-1');
assert.equal(res.body.submitted, true);
});

View File

@@ -10,6 +10,8 @@ import { rapidOrderParseSchema } from '../contracts/core/rapid-order-parse.js';
import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js';
import { reviewVerificationSchema } from '../contracts/core/review-verification.js';
import { invokeVertexModel } from '../services/llm.js';
import { requireTenantContext } from '../services/actor-context.js';
import { isDatabaseConfigured, query as dbQuery } from '../services/db.js';
import { checkLlmRateLimit } from '../services/llm-rate-limit.js';
import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js';
import {
@@ -26,6 +28,8 @@ import {
} from '../services/verification-jobs.js';
import {
deleteCertificate,
finalizeCertificateUpload,
finalizeStaffDocumentUpload,
uploadCertificate,
uploadProfilePhoto,
uploadStaffDocument,
@@ -70,6 +74,35 @@ const certificateUploadMetaSchema = z.object({
expiresAt: z.string().datetime().optional(),
});
const finalizedDocumentUploadSchema = z.object({
fileUri: z.string().max(4096).optional(),
photoUrl: z.string().max(4096).optional(),
verificationId: z.string().min(1).max(120),
}).strict();
const finalizedCertificateUploadSchema = certificateUploadMetaSchema.extend({
fileUri: z.string().max(4096).optional(),
photoUrl: z.string().max(4096).optional(),
verificationId: z.string().min(1).max(120),
}).strict();
const rapidOrderProcessSchema = z.object({
text: z.string().trim().min(1).max(4000).optional(),
audioFileUri: z.string().startsWith('gs://').max(2048).optional(),
locale: z.string().trim().min(2).max(35).optional().default('en-US'),
promptHints: z.array(z.string().trim().min(1).max(80)).max(20).optional().default([]),
timezone: z.string().trim().min(1).max(80).optional(),
now: z.string().datetime({ offset: true }).optional(),
}).strict().superRefine((value, ctx) => {
if (!value.text && !value.audioFileUri) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'text or audioFileUri is required',
path: ['text'],
});
}
});
function mockSignedUrl(fileUri, expiresInSeconds) {
const encoded = encodeURIComponent(fileUri);
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
@@ -114,6 +147,72 @@ function enforceLlmRateLimit(uid) {
}
}
function normalizeRoleToken(value) {
return `${value || ''}`
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async function loadRapidOrderRoleCatalog(actorUid) {
if (!isDatabaseConfigured()) {
return [];
}
let context;
try {
context = await requireTenantContext(actorUid);
} catch {
return [];
}
const result = await dbQuery(
`
SELECT
rc.id AS "roleId",
rc.code AS "roleCode",
rc.name AS "roleName",
COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents"
FROM roles_catalog rc
LEFT JOIN shift_roles sr ON sr.role_id = rc.id
LEFT JOIN shifts s ON s.id = sr.shift_id
WHERE rc.tenant_id = $1
AND rc.status = 'ACTIVE'
GROUP BY rc.id
ORDER BY rc.name ASC
`,
[context.tenant.tenantId]
);
return result.rows;
}
function enrichRapidOrderPositions(positions, roleCatalog) {
const catalog = roleCatalog.map((role) => ({
...role,
normalizedName: normalizeRoleToken(role.roleName),
normalizedCode: normalizeRoleToken(role.roleCode),
}));
return positions.map((position) => {
const normalizedRole = normalizeRoleToken(position.role);
const exact = catalog.find((role) => role.normalizedName === normalizedRole || role.normalizedCode === normalizedRole);
const fuzzy = exact || catalog.find((role) => (
role.normalizedName.includes(normalizedRole) || normalizedRole.includes(role.normalizedName)
));
return {
...position,
roleId: fuzzy?.roleId || null,
roleCode: fuzzy?.roleCode || null,
roleName: fuzzy?.roleName || position.role,
hourlyRateCents: fuzzy?.hourlyRateCents || 0,
matched: Boolean(fuzzy),
};
});
}
async function handleUploadFile(req, res, next) {
try {
const file = req.file;
@@ -280,9 +379,74 @@ async function handleRapidOrderParse(req, res, next) {
timezone: payload.timezone,
now: payload.now,
});
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
return res.status(200).json({
...result,
parsed: {
...result.parsed,
positions: enrichRapidOrderPositions(result.parsed.positions, roleCatalog),
},
catalog: {
roles: roleCatalog,
},
latencyMs: Date.now() - startedAt,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleRapidOrderProcess(req, res, next) {
try {
const payload = parseBody(rapidOrderProcessSchema, req.body || {});
enforceLlmRateLimit(req.actor.uid);
let transcript = payload.text || null;
if (!transcript && payload.audioFileUri) {
validateFileUriAccess({
fileUri: payload.audioFileUri,
actorUid: req.actor.uid,
});
if (requireRapidAudioFileExists() && !useMockUpload()) {
await ensureFileExistsForActor({
fileUri: payload.audioFileUri,
actorUid: req.actor.uid,
});
}
const transcribed = await transcribeRapidOrderAudio({
audioFileUri: payload.audioFileUri,
locale: payload.locale,
promptHints: payload.promptHints,
});
transcript = transcribed.transcript;
}
const startedAt = Date.now();
const parsed = await parseRapidOrderText({
text: transcript,
locale: payload.locale,
timezone: payload.timezone,
now: payload.now,
});
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
return res.status(200).json({
transcript,
parsed: {
...parsed.parsed,
positions: enrichRapidOrderPositions(parsed.parsed.positions, roleCatalog),
},
missingFields: parsed.missingFields,
warnings: parsed.warnings,
confidence: parsed.confidence,
catalog: {
roles: roleCatalog,
},
model: parsed.model,
latencyMs: Date.now() - startedAt,
requestId: req.requestId,
});
@@ -341,9 +505,7 @@ async function handleProfilePhotoUpload(req, res, next) {
async function handleDocumentUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
}
if (file) {
const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
@@ -354,6 +516,19 @@ async function handleDocumentUpload(req, res, next) {
...result,
requestId: req.requestId,
});
}
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
const result = await finalizeStaffDocumentUpload({
actorUid: req.actor.uid,
documentId: req.params.documentId,
routeType: 'document',
verificationId: payload.verificationId,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
@@ -362,9 +537,7 @@ async function handleDocumentUpload(req, res, next) {
async function handleAttireUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
}
if (file) {
const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
@@ -375,6 +548,19 @@ async function handleAttireUpload(req, res, next) {
...result,
requestId: req.requestId,
});
}
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
const result = await finalizeStaffDocumentUpload({
actorUid: req.actor.uid,
documentId: req.params.documentId,
routeType: 'attire',
verificationId: payload.verificationId,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
@@ -383,9 +569,7 @@ async function handleAttireUpload(req, res, next) {
async function handleCertificateUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
}
if (file) {
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
const result = await uploadCertificate({
actorUid: req.actor.uid,
@@ -396,6 +580,17 @@ async function handleCertificateUpload(req, res, next) {
...result,
requestId: req.requestId,
});
}
const payload = parseBody(finalizedCertificateUploadSchema, req.body || {});
const result = await finalizeCertificateUpload({
actorUid: req.actor.uid,
payload,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
@@ -464,9 +659,12 @@ export function createCoreRouter() {
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
router.post('/rapid-orders/process', requireAuth, requirePolicy('core.rapid-order.process', 'model'), handleRapidOrderProcess);
router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload);
router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload);
router.put('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleDocumentUpload);
router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
router.put('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleAttireUpload);
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);

View File

@@ -2,7 +2,7 @@ import { AppError } from '../lib/errors.js';
import { requireStaffContext } from './actor-context.js';
import { generateReadSignedUrl, uploadToGcs } from './storage.js';
import { query, withTransaction } from './db.js';
import { createVerificationJob } from './verification-jobs.js';
import { createVerificationJob, getVerificationJob } from './verification-jobs.js';
function safeName(value) {
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
@@ -40,6 +40,53 @@ async function createPreviewUrl(actorUid, fileUri) {
}
}
function normalizeDocumentStatusFromVerification(status) {
switch (`${status || ''}`.toUpperCase()) {
case 'AUTO_PASS':
case 'APPROVED':
return 'VERIFIED';
case 'AUTO_FAIL':
case 'REJECTED':
return 'REJECTED';
default:
return 'PENDING';
}
}
async function resolveVerificationBackedUpload({
actorUid,
verificationId,
subjectId,
allowedTypes,
}) {
if (!verificationId) {
throw new AppError('VALIDATION_ERROR', 'verificationId is required for finalized upload submission', 400);
}
const verification = await getVerificationJob(verificationId, actorUid);
if (subjectId && verification.subjectId && verification.subjectId !== subjectId) {
throw new AppError('VALIDATION_ERROR', 'verificationId does not belong to the requested subject', 400, {
verificationId,
subjectId,
verificationSubjectId: verification.subjectId,
});
}
if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(verification.type)) {
throw new AppError('VALIDATION_ERROR', 'verificationId type does not match the requested upload', 400, {
verificationId,
verificationType: verification.type,
allowedTypes,
});
}
return {
verification,
fileUri: verification.fileUri,
status: normalizeDocumentStatusFromVerification(verification.status),
};
}
export async function uploadProfilePhoto({ actorUid, file }) {
const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({
@@ -163,6 +210,76 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp
};
}
export async function finalizeStaffDocumentUpload({
actorUid,
documentId,
routeType,
verificationId,
}) {
const context = await requireStaffContext(actorUid);
const document = await requireDocument(
context.tenant.tenantId,
documentId,
routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM']
);
const finalized = await resolveVerificationBackedUpload({
actorUid,
verificationId,
subjectId: documentId,
allowedTypes: routeType === 'attire'
? ['attire']
: ['government_id', 'document', 'tax_form'],
});
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO staff_documents (
tenant_id,
staff_id,
document_id,
file_uri,
status,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (staff_id, document_id) DO UPDATE
SET file_uri = EXCLUDED.file_uri,
status = EXCLUDED.status,
verification_job_id = EXCLUDED.verification_job_id,
metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
`,
[
context.tenant.tenantId,
context.staff.staffId,
document.id,
finalized.fileUri,
finalized.status,
finalized.verification.verificationId,
JSON.stringify({
verificationStatus: finalized.verification.status,
routeType,
finalizedFromVerification: true,
}),
]
);
});
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
return {
documentId: document.id,
documentType: document.document_type,
fileUri: finalized.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification: finalized.verification,
status: finalized.status,
};
}
export async function uploadCertificate({ actorUid, file, payload }) {
const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({
@@ -236,6 +353,106 @@ export async function uploadCertificate({ actorUid, file, payload }) {
};
}
export async function finalizeCertificateUpload({ actorUid, payload }) {
const context = await requireStaffContext(actorUid);
const finalized = await resolveVerificationBackedUpload({
actorUid,
verificationId: payload.verificationId,
subjectId: payload.certificateType,
allowedTypes: ['certification'],
});
const certificateResult = await withTransaction(async (client) => {
const existing = await client.query(
`
SELECT id
FROM certificates
WHERE tenant_id = $1
AND staff_id = $2
AND certificate_type = $3
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE
`,
[context.tenant.tenantId, context.staff.staffId, payload.certificateType]
);
const metadata = JSON.stringify({
name: payload.name,
issuer: payload.issuer || null,
verificationStatus: finalized.verification.status,
finalizedFromVerification: true,
});
if (existing.rowCount > 0) {
return client.query(
`
UPDATE certificates
SET certificate_number = $2,
expires_at = $3,
status = $4,
file_uri = $5,
verification_job_id = $6,
metadata = COALESCE(metadata, '{}'::jsonb) || $7::jsonb,
updated_at = NOW()
WHERE id = $1
RETURNING id
`,
[
existing.rows[0].id,
payload.certificateNumber || null,
payload.expiresAt || null,
finalized.status,
finalized.fileUri,
finalized.verification.verificationId,
metadata,
]
);
}
return client.query(
`
INSERT INTO certificates (
tenant_id,
staff_id,
certificate_type,
certificate_number,
issued_at,
expires_at,
status,
file_uri,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7, $8, $9::jsonb)
RETURNING id
`,
[
context.tenant.tenantId,
context.staff.staffId,
payload.certificateType,
payload.certificateNumber || null,
payload.expiresAt || null,
finalized.status,
finalized.fileUri,
finalized.verification.verificationId,
metadata,
]
);
});
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
return {
certificateId: certificateResult.rows[0].id,
certificateType: payload.certificateType,
fileUri: finalized.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification: finalized.verification,
status: finalized.status,
};
}
export async function deleteCertificate({ actorUid, certificateType }) {
const context = await requireStaffContext(actorUid);
const result = await query(

View File

@@ -267,6 +267,25 @@ test('POST /core/rapid-orders/parse rejects unknown fields', async () => {
assert.equal(res.body.code, 'VALIDATION_ERROR');
});
test('POST /core/rapid-orders/process accepts text-only flow', async () => {
const app = createApp();
const res = await request(app)
.post('/core/rapid-orders/process')
.set('Authorization', 'Bearer test-token')
.send({
text: 'Need 2 servers ASAP for 4 hours',
locale: 'en-US',
timezone: 'America/New_York',
now: '2026-02-27T12:00:00.000Z',
});
assert.equal(res.status, 200);
assert.equal(typeof res.body.transcript, 'string');
assert.equal(res.body.parsed.orderType, 'ONE_TIME');
assert.equal(Array.isArray(res.body.parsed.positions), true);
assert.equal(Array.isArray(res.body.catalog.roles), true);
});
test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => {
process.env.LLM_RATE_LIMIT_PER_MINUTE = '1';
const app = createApp();

View File

@@ -171,17 +171,32 @@ export async function listRecentReorders(actorUid, limit) {
o.id,
o.title,
o.starts_at AS "date",
COALESCE(cp.label, o.location_name) AS "hubName",
COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType"
MAX(COALESCE(cp.label, o.location_name)) AS "hubName",
MAX(b.business_name) AS "clientName",
COALESCE(SUM(sr.workers_needed), 0)::INTEGER AS "positionCount",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
COALESCE(ROUND(AVG(sr.bill_rate_cents))::INTEGER, 0) AS "hourlyRateCents",
COALESCE(
SUM(
sr.bill_rate_cents
* sr.workers_needed
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
),
0
)::BIGINT AS "totalPriceCents",
COALESCE(
SUM(GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)),
0
)::NUMERIC(12,2) AS hours
FROM orders o
JOIN businesses b ON b.id = o.business_id
LEFT JOIN shifts s ON s.order_id = o.id
LEFT JOIN shift_roles sr ON sr.shift_id = s.id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE o.tenant_id = $1
AND o.business_id = $2
AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED')
GROUP BY o.id, cp.label
GROUP BY o.id
ORDER BY o.starts_at DESC NULLS LAST
LIMIT $3
`,
@@ -520,15 +535,33 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
sr.id AS "itemId",
o.id AS "orderId",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
o.title AS "eventName",
b.business_name AS "clientName",
sr.role_name AS title,
sr.role_name AS "roleName",
s.starts_at AS date,
to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
s.starts_at AS "startsAt",
s.ends_at AS "endsAt",
to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI') AS "startTime",
to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI') AS "endTime",
sr.workers_needed AS "requiredWorkerCount",
sr.assigned_count AS "filledCount",
sr.bill_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.bill_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)::NUMERIC(12,2) AS hours,
(sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents",
ROUND(
(
sr.bill_rate_cents
* sr.workers_needed
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalValue",
COALESCE(cp.label, s.location_name) AS "locationName",
COALESCE(s.location_address, cp.address) AS "locationAddress",
hm.business_membership_id AS "hubManagerId",
COALESCE(u.display_name, u.email) AS "hubManagerName",
s.status,
COALESCE(
json_agg(
@@ -544,14 +577,34 @@ export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }
FROM shift_roles sr
JOIN shifts s ON s.id = sr.shift_id
JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = o.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN assignments a ON a.shift_role_id = sr.id
LEFT JOIN staffs st ON st.id = a.staff_id
LEFT JOIN LATERAL (
SELECT business_membership_id
FROM hub_managers
WHERE tenant_id = o.tenant_id
AND hub_id = s.clock_point_id
ORDER BY created_at ASC
LIMIT 1
) hm ON TRUE
LEFT JOIN business_memberships bm ON bm.id = hm.business_membership_id
LEFT JOIN users u ON u.id = bm.user_id
WHERE o.tenant_id = $1
AND o.business_id = $2
AND s.starts_at >= $3::timestamptz
AND s.starts_at <= $4::timestamptz
GROUP BY sr.id, o.id, s.id, cp.label
GROUP BY
sr.id,
o.id,
s.id,
cp.label,
cp.address,
b.business_name,
hm.business_membership_id,
u.display_name,
u.email
ORDER BY s.starts_at ASC, sr.role_name ASC
`,
[context.tenant.tenantId, context.business.businessId, range.start, range.end]
@@ -633,6 +686,23 @@ export async function listTodayShifts(actorUid) {
COALESCE(s.title, sr.role_name || ' shift') AS title,
b.business_name AS "clientName",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location,
COALESCE(s.location_address, cp.address) AS "locationAddress",
@@ -656,7 +726,7 @@ export async function listTodayShifts(actorUid) {
AND a.staff_id = $2
AND s.starts_at >= $3::timestamptz
AND s.starts_at < $4::timestamptz
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC
`,
[context.tenant.tenantId, context.staff.staffId, from, to]
@@ -767,24 +837,43 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) {
SELECT
a.id AS "assignmentId",
s.id AS "shiftId",
b.business_name AS "clientName",
sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location,
s.starts_at AS date,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
a.status
FROM assignments a
JOIN shifts s ON s.id = a.shift_id
JOIN shift_roles sr ON sr.id = a.shift_role_id
JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE a.tenant_id = $1
AND a.staff_id = $2
AND s.starts_at >= $3::timestamptz
AND s.starts_at <= $4::timestamptz
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
ORDER BY s.starts_at ASC
`,
[context.tenant.tenantId, context.staff.staffId, range.start, range.end]
@@ -800,18 +889,37 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
SELECT
s.id AS "shiftId",
sr.id AS "roleId",
b.business_name AS "clientName",
sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location,
s.starts_at AS date,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
FALSE AS "instantBook",
sr.workers_needed AS "requiredWorkerCount"
FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id
JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE s.tenant_id = $1
AND s.status = 'OPEN'
@@ -829,12 +937,30 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
SELECT
s.id AS "shiftId",
sr.id AS "roleId",
b.business_name AS "clientName",
sr.role_name AS "roleName",
COALESCE(cp.label, s.location_name) AS location,
s.starts_at AS date,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
FALSE AS "instantBook",
1::INTEGER AS "requiredWorkerCount"
@@ -842,6 +968,7 @@ export async function listOpenShifts(actorUid, { limit, search } = {}) {
JOIN shifts s ON s.id = a.shift_id
JOIN shift_roles sr ON sr.id = a.shift_role_id
JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
WHERE a.tenant_id = $1
AND a.status = 'SWAP_REQUESTED'
@@ -911,8 +1038,11 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
s.id AS "shiftId",
s.title,
o.description,
b.business_name AS "clientName",
COALESCE(cp.label, s.location_name) AS location,
s.location_address AS address,
COALESCE(s.location_address, cp.address) AS address,
COALESCE(s.latitude, cp.latitude) AS latitude,
COALESCE(s.longitude, cp.longitude) AS longitude,
s.starts_at AS date,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
@@ -923,6 +1053,23 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
sr.id AS "roleId",
sr.role_name AS "roleName",
sr.pay_rate_cents AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::INTEGER,
0
) AS "totalRateCents",
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)::numeric / 100,
2
) AS "totalRate",
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
sr.workers_needed AS "requiredCount",
sr.assigned_count AS "confirmedCount",
@@ -930,6 +1077,7 @@ export async function getStaffShiftDetail(actorUid, shiftId) {
app.status AS "applicationStatus"
FROM shifts s
JOIN orders o ON o.id = s.order_id
JOIN businesses b ON b.id = s.business_id
JOIN shift_roles sr ON sr.shift_id = s.id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3
@@ -981,12 +1129,41 @@ export async function listCompletedShifts(actorUid) {
a.id AS "assignmentId",
s.id AS "shiftId",
s.title,
b.business_name AS "clientName",
COALESCE(cp.label, s.location_name) AS location,
s.starts_at AS date,
to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
s.starts_at AS "startTime",
s.ends_at AS "endTime",
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
ROUND(COALESCE(sr.pay_rate_cents, 0)::numeric / 100, 2) AS "hourlyRate",
COALESCE(ts.status, 'PENDING') AS "timesheetStatus",
COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked",
COALESCE(
ts.gross_pay_cents,
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::BIGINT
) AS "totalRateCents",
ROUND(
COALESCE(
ts.gross_pay_cents,
ROUND(
(
COALESCE(sr.pay_rate_cents, 0)
* GREATEST(EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600, 0)
)
)::BIGINT
)::numeric / 100,
2
) AS "totalRate",
COALESCE(rp.status, 'PENDING') AS "paymentStatus"
FROM assignments a
JOIN shifts s ON s.id = a.shift_id
JOIN businesses b ON b.id = s.business_id
LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
LEFT JOIN timesheets ts ON ts.assignment_id = a.id
LEFT JOIN recent_payments rp ON rp.assignment_id = a.id
@@ -1003,19 +1180,22 @@ export async function listCompletedShifts(actorUid) {
export async function getProfileSectionsStatus(actorUid) {
const context = await requireStaffContext(actorUid);
const completion = getProfileCompletionFromMetadata(context.staff);
const [documents, certificates, benefits] = await Promise.all([
const [documents, certificates, benefits, attire, taxForms] = await Promise.all([
listProfileDocuments(actorUid),
listCertificates(actorUid),
listStaffBenefits(actorUid),
listAttireChecklist(actorUid),
listTaxForms(actorUid),
]);
return {
personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations,
emergencyContactCompleted: completion.fields.emergencyContact,
experienceCompleted: completion.fields.skills && completion.fields.industries,
attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'),
taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'),
attireCompleted: attire.every((item) => item.status === 'VERIFIED'),
taxFormsCompleted: taxForms.every((item) => item.status === 'VERIFIED' || item.status === 'SUBMITTED'),
benefitsConfigured: benefits.length > 0,
certificateCount: certificates.length,
documentCount: documents.length,
};
}
@@ -1054,6 +1234,7 @@ export async function listProfileDocuments(actorUid) {
d.id AS "documentId",
d.document_type AS "documentType",
d.name,
COALESCE(d.metadata->>'description', '') AS description,
sd.id AS "staffDocumentId",
sd.file_uri AS "fileUri",
COALESCE(sd.status, 'NOT_UPLOADED') AS status,
@@ -1065,7 +1246,7 @@ export async function listProfileDocuments(actorUid) {
AND sd.tenant_id = d.tenant_id
AND sd.staff_id = $2
WHERE d.tenant_id = $1
AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM')
AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID')
ORDER BY d.name ASC
`,
[context.tenant.tenantId, context.staff.staffId]
@@ -1645,9 +1826,12 @@ export async function listTaxForms(actorUid) {
SELECT
d.id AS "documentId",
d.name AS "formType",
COALESCE(d.metadata->>'description', '') AS description,
sd.id AS "staffDocumentId",
sd.file_uri AS "fileUri",
COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status,
COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields
COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields,
sd.expires_at AS "expiresAt"
FROM documents d
LEFT JOIN staff_documents sd
ON sd.document_id = d.id

View File

@@ -87,6 +87,70 @@ async function uploadFile(path, token, {
return payload;
}
async function finalizeVerifiedUpload({
token,
uploadCategory,
filename,
contentType,
content,
finalizePath,
finalizeMethod = 'PUT',
verificationType,
subjectId,
rules = {},
finalizeBody = {},
}) {
const uploaded = await uploadFile('/upload-file', token, {
filename,
contentType,
content,
fields: {
visibility: 'private',
category: uploadCategory,
},
});
const signed = await apiCall('/create-signed-url', {
method: 'POST',
token,
body: {
fileUri: uploaded.fileUri,
expiresInSeconds: 300,
},
});
const verification = await apiCall('/verifications', {
method: 'POST',
token,
body: {
type: verificationType,
subjectType: 'worker',
subjectId,
fileUri: uploaded.fileUri,
rules,
},
expectedStatus: 202,
});
const finalized = await apiCall(finalizePath, {
method: finalizeMethod,
token,
body: {
...finalizeBody,
verificationId: verification.verificationId,
fileUri: signed.signedUrl,
photoUrl: signed.signedUrl,
},
});
return {
uploaded,
signed,
verification,
finalized,
};
}
async function signInClient() {
return apiCall('/auth/client/sign-in', {
method: 'POST',
@@ -210,6 +274,10 @@ async function main() {
token: ownerSession.sessionToken,
});
assert.ok(Array.isArray(clientReorders.items));
if (clientReorders.items[0]) {
assert.equal(typeof clientReorders.items[0].hourlyRateCents, 'number');
assert.equal(typeof clientReorders.items[0].totalPriceCents, 'number');
}
logStep('client.reorders.ok', { count: clientReorders.items.length });
const billingAccounts = await apiCall('/client/billing/accounts', {
@@ -317,6 +385,10 @@ async function main() {
token: ownerSession.sessionToken,
});
assert.ok(Array.isArray(viewedOrders.items));
if (viewedOrders.items[0]) {
assert.ok(viewedOrders.items[0].clientName);
assert.equal(typeof viewedOrders.items[0].hourlyRate, 'number');
}
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
@@ -519,7 +591,7 @@ async function main() {
assert.ok(createdPermanentOrder.orderId);
logStep('client.orders.create-permanent.ok', createdPermanentOrder);
const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, {
const editedOrderCopy = await apiCall(`/client/orders/${createdRecurringOrder.orderId}/edit`, {
method: 'POST',
token: ownerSession.sessionToken,
idempotencyKey: uniqueKey('order-edit'),
@@ -528,6 +600,7 @@ async function main() {
},
});
assert.ok(editedOrderCopy.orderId);
assert.notEqual(editedOrderCopy.orderId, createdRecurringOrder.orderId);
logStep('client.orders.edit-copy.ok', editedOrderCopy);
const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, {
@@ -538,6 +611,7 @@ async function main() {
reason: 'Smoke cancel validation',
},
});
assert.equal(cancelledOrder.futureOnly, true);
logStep('client.orders.cancel.ok', cancelledOrder);
const coverageReview = await apiCall('/client/coverage/reviews', {
@@ -609,6 +683,14 @@ async function main() {
token: staffAuth.idToken,
});
assert.ok(Array.isArray(staffDashboard.recommendedShifts));
if (staffDashboard.todaysShifts[0]) {
assert.ok(staffDashboard.todaysShifts[0].clientName);
assert.equal(typeof staffDashboard.todaysShifts[0].totalRate, 'number');
}
if (staffDashboard.recommendedShifts[0]) {
assert.ok(staffDashboard.recommendedShifts[0].clientName);
assert.equal(typeof staffDashboard.recommendedShifts[0].totalRate, 'number');
}
logStep('staff.dashboard.ok', {
todaysShifts: staffDashboard.todaysShifts.length,
recommendedShifts: staffDashboard.recommendedShifts.length,
@@ -693,12 +775,22 @@ async function main() {
token: staffAuth.idToken,
});
assert.ok(Array.isArray(completedShifts.items));
if (completedShifts.items[0]) {
assert.ok(completedShifts.items[0].clientName);
assert.ok(completedShifts.items[0].date);
assert.ok(completedShifts.items[0].startTime);
assert.ok(completedShifts.items[0].endTime);
assert.equal(typeof completedShifts.items[0].hourlyRate, 'number');
assert.equal(typeof completedShifts.items[0].totalRate, 'number');
}
logStep('staff.shifts.completed.ok', { count: completedShifts.items.length });
const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, {
token: staffAuth.idToken,
});
assert.equal(shiftDetail.shiftId, openShift.shiftId);
assert.equal(typeof shiftDetail.latitude, 'number');
assert.equal(typeof shiftDetail.longitude, 'number');
logStep('staff.shifts.detail.ok', shiftDetail);
const profileSections = await apiCall('/staff/profile/sections', {
@@ -727,6 +819,7 @@ async function main() {
token: staffAuth.idToken,
});
assert.ok(Array.isArray(profileDocumentsBefore.items));
assert.ok(profileDocumentsBefore.items.every((item) => item.documentType !== 'ATTIRE'));
logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length });
const attireChecklistBefore = await apiCall('/staff/profile/attire', {
@@ -1054,6 +1147,17 @@ 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`, {
method: 'POST',
token: staffAuth.idToken,
idempotencyKey: uniqueKey('staff-shift-submit-approval'),
body: {
note: 'Smoke approval submission',
},
});
assert.equal(submittedCompletedShift.submitted, true);
logStep('staff.shifts.submit-for-approval.ok', submittedCompletedShift);
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
method: 'POST',
token: staffAuth.idToken,
@@ -1072,35 +1176,63 @@ async function main() {
assert.ok(uploadedProfilePhoto.fileUri);
logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto);
const uploadedGovId = await uploadFile(`/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, staffAuth.idToken, {
const uploadedGovId = await finalizeVerifiedUpload({
token: staffAuth.idToken,
uploadCategory: 'staff-document',
filename: 'government-id.jpg',
contentType: 'image/jpeg',
content: Buffer.from('fake-government-id'),
finalizePath: `/staff/profile/documents/${fixture.documents.governmentId.id}/upload`,
finalizeMethod: 'PUT',
verificationType: 'government_id',
subjectId: fixture.documents.governmentId.id,
rules: {
documentId: fixture.documents.governmentId.id,
},
});
assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id);
logStep('staff.profile.document.upload.ok', uploadedGovId);
assert.equal(uploadedGovId.finalized.documentId, fixture.documents.governmentId.id);
logStep('staff.profile.document.upload.ok', uploadedGovId.finalized);
const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, {
const uploadedAttire = await finalizeVerifiedUpload({
token: staffAuth.idToken,
uploadCategory: 'staff-attire',
filename: 'black-shirt.jpg',
contentType: 'image/jpeg',
content: Buffer.from('fake-black-shirt'),
finalizePath: `/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`,
finalizeMethod: 'PUT',
verificationType: 'attire',
subjectId: fixture.documents.attireBlackShirt.id,
rules: {
dressCode: 'Black shirt',
},
});
assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id);
logStep('staff.profile.attire.upload.ok', uploadedAttire);
assert.equal(uploadedAttire.finalized.documentId, fixture.documents.attireBlackShirt.id);
logStep('staff.profile.attire.upload.ok', uploadedAttire.finalized);
const certificateType = `ALCOHOL_SERVICE_${Date.now()}`;
const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, {
const uploadedCertificate = await finalizeVerifiedUpload({
token: staffAuth.idToken,
uploadCategory: 'staff-certificate',
filename: 'certificate.pdf',
contentType: 'application/pdf',
content: Buffer.from('fake-certificate'),
fields: {
finalizePath: '/staff/profile/certificates',
finalizeMethod: 'POST',
verificationType: 'certification',
subjectId: certificateType,
rules: {
certificateName: 'Alcohol Service Permit',
certificateIssuer: 'Demo Issuer',
},
finalizeBody: {
certificateType,
name: 'Alcohol Service Permit',
issuer: 'Demo Issuer',
},
});
assert.equal(uploadedCertificate.certificateType, certificateType);
logStep('staff.profile.certificate.upload.ok', uploadedCertificate);
assert.equal(uploadedCertificate.finalized.certificateType, certificateType);
logStep('staff.profile.certificate.upload.ok', uploadedCertificate.finalized);
const profileDocumentsAfter = await apiCall('/staff/profile/documents', {
token: staffAuth.idToken,

View File

@@ -20,14 +20,15 @@ const DIRECT_CORE_ALIASES = [
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/process$/, targetPath: (pathname) => `/core${pathname}` },
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
{
methods: new Set(['POST']),
methods: new Set(['POST', 'PUT']),
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
},
{
methods: new Set(['POST']),
methods: new Set(['POST', 'PUT']),
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
},

View File

@@ -182,3 +182,56 @@ test('proxy forwards direct core upload aliases to core api', async () => {
assert.equal(res.status, 200);
assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload');
});
test('proxy forwards PUT document upload aliases to core api', async () => {
process.env.QUERY_API_BASE_URL = 'https://query.example';
process.env.CORE_API_BASE_URL = 'https://core.example';
process.env.COMMAND_API_BASE_URL = 'https://command.example';
let seenUrl = null;
let seenMethod = null;
const app = createApp({
fetchImpl: async (url, init = {}) => {
seenUrl = `${url}`;
seenMethod = init.method;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
const res = await request(app)
.put('/staff/profile/documents/doc-1/upload')
.set('Authorization', 'Bearer test-token')
.send({ verificationId: 'verification-1' });
assert.equal(res.status, 200);
assert.equal(seenMethod, 'PUT');
assert.equal(seenUrl, 'https://core.example/core/staff/documents/doc-1/upload');
});
test('proxy forwards rapid order process alias to core api', async () => {
process.env.QUERY_API_BASE_URL = 'https://query.example';
process.env.CORE_API_BASE_URL = 'https://core.example';
process.env.COMMAND_API_BASE_URL = 'https://command.example';
let seenUrl = null;
const app = createApp({
fetchImpl: async (url) => {
seenUrl = `${url}`;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});
const res = await request(app)
.post('/rapid-orders/process')
.set('Authorization', 'Bearer test-token')
.send({ text: 'Need 2 servers ASAP for 4 hours' });
assert.equal(res.status, 200);
assert.equal(seenUrl, 'https://core.example/core/rapid-orders/process');
});

View File

@@ -143,6 +143,7 @@ Those routes still exist for backend/internal compatibility, but mobile/frontend
- [Authentication](./authentication.md)
- [Unified API](./unified-api.md)
- [Staff Shifts](./staff-shifts.md)
- [Core API](./core-api.md)
- [Command API](./command-api.md)
- [Query API](./query-api.md)

View File

@@ -0,0 +1,176 @@
# Staff Shifts V2
This document is the frontend handoff for the `staff/shifts/*` routes on the unified v2 API.
Base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app`
## Read routes
- `GET /staff/shifts/assigned`
- `GET /staff/shifts/open`
- `GET /staff/shifts/pending`
- `GET /staff/shifts/cancelled`
- `GET /staff/shifts/completed`
- `GET /staff/shifts/:shiftId`
## Write routes
- `POST /staff/shifts/:shiftId/apply`
- `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline`
- `POST /staff/shifts/:shiftId/request-swap`
- `POST /staff/shifts/:shiftId/submit-for-approval`
All write routes require:
- `Authorization: Bearer <firebase-id-token>`
- `Idempotency-Key: <unique-per-action>`
## Shift lifecycle
### Find shifts
`GET /staff/shifts/open`
- use this for the worker marketplace feed
- the worker applies to a concrete shift role
- send the `roleId` returned by the open-shifts response
- `roleId` here means `shift_roles.id`, not the role catalog id
Apply request example:
```json
{
"roleId": "uuid",
"instantBook": false
}
```
### Pending shifts
`GET /staff/shifts/pending`
- use `POST /staff/shifts/:shiftId/accept` to accept
- use `POST /staff/shifts/:shiftId/decline` to decline
### Assigned shifts
`GET /staff/shifts/assigned`
Each item now includes:
- `clientName`
- `hourlyRate`
- `totalRate`
- `startTime`
- `endTime`
### Shift detail
`GET /staff/shifts/:shiftId`
Each detail response now includes:
- `clientName`
- `latitude`
- `longitude`
- `hourlyRate`
- `totalRate`
Use this as the source of truth for the shift detail screen.
### Request swap
`POST /staff/shifts/:shiftId/request-swap`
Example:
```json
{
"reason": "Need coverage for a family emergency"
}
```
Current backend behavior:
- marks the assignment as `SWAP_REQUESTED`
- stores the reason
- emits `SHIFT_SWAP_REQUESTED`
- exposes the shift in the replacement pool
This is enough for the current staff UI.
It is not yet the full manager-side swap resolution lifecycle.
### Submit completed shift for approval
`POST /staff/shifts/:shiftId/submit-for-approval`
Use this after the worker has clocked out.
Example:
```json
{
"note": "Worked full shift and all tasks were completed"
}
```
Current backend behavior:
- only allows shifts in `CHECKED_OUT` or `COMPLETED`
- creates or updates the assignment timesheet
- sets the timesheet to `SUBMITTED` unless it is already `APPROVED` or `PAID`
- emits `TIMESHEET_SUBMITTED_FOR_APPROVAL`
Example response:
```json
{
"assignmentId": "uuid",
"shiftId": "uuid",
"timesheetId": "uuid",
"status": "SUBMITTED",
"submitted": true
}
```
## Completed shifts
`GET /staff/shifts/completed`
Each item now includes:
- `date`
- `clientName`
- `startTime`
- `endTime`
- `hourlyRate`
- `totalRate`
- `timesheetStatus`
- `paymentStatus`
## Clock-in support fields
`GET /staff/clock-in/shifts/today`
Each item now includes:
- `clientName`
- `hourlyRate`
- `totalRate`
- `latitude`
- `longitude`
- `clockInMode`
- `allowClockInOverride`
## Frontend rule
Use the unified routes only.
Do not build new mobile work on:
- `/query/*`
- `/commands/*`
- `/core/*`

View File

@@ -153,6 +153,7 @@ Example `GET /staff/clock-in/shifts/today` item:
- `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline`
- `POST /staff/shifts/:shiftId/request-swap`
- `POST /staff/shifts/:shiftId/submit-for-approval`
- `PUT /staff/profile/personal-info`
- `PUT /staff/profile/experience`
- `PUT /staff/profile/locations`
@@ -174,6 +175,7 @@ These are exposed as direct unified aliases even though they are backed by `core
- `POST /invoke-llm`
- `POST /rapid-orders/transcribe`
- `POST /rapid-orders/parse`
- `POST /rapid-orders/process`
- `POST /verifications`
- `GET /verifications/:verificationId`
- `POST /verifications/:verificationId/review`
@@ -183,7 +185,9 @@ These are exposed as direct unified aliases even though they are backed by `core
- `POST /staff/profile/photo`
- `POST /staff/profile/documents/:documentId/upload`
- `PUT /staff/profile/documents/:documentId/upload`
- `POST /staff/profile/attire/:documentId/upload`
- `PUT /staff/profile/attire/:documentId/upload`
- `POST /staff/profile/certificates`
- `DELETE /staff/profile/certificates/:certificateId`
@@ -191,7 +195,21 @@ These are exposed as direct unified aliases even though they are backed by `core
- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id.
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
- Document routes now return only document rows. They do not mix in attire items anymore.
- Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`.
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
- The frontend upload contract for documents, attire, and certificates is:
1. `POST /upload-file`
2. `POST /create-signed-url`
3. `POST /verifications`
4. finalize with:
- `PUT /staff/profile/documents/:documentId/upload`
- `PUT /staff/profile/attire/:documentId/upload`
- `POST /staff/profile/certificates`
- Finalization requires `verificationId`. Frontend may still send `fileUri` or `photoUrl`, but the backend treats the verification-linked file as the source of truth.
- `POST /rapid-orders/process` is the single-call route for "transcribe + parse".
- `POST /client/orders/:orderId/edit` builds a replacement order from future shifts only.
- `POST /client/orders/:orderId/cancel` cancels future shifts only on the mobile surface and leaves historical shifts intact.
- Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`.
- Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`.
- `clockInMode` values are: