From 008dd7efb13ff0631a374c73495ce6679872f0d7 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:37:45 +0100 Subject: [PATCH] fix(api): close v2 mobile contract gaps --- .../src/contracts/commands/mobile.js | 5 + backend/command-api/src/routes/mobile.js | 11 + .../src/services/mobile-command-service.js | 248 +++++++++++++++++- .../command-api/test/mobile-routes.test.js | 22 ++ backend/core-api/src/routes/core.js | 224 +++++++++++++++- .../core-api/src/services/mobile-upload.js | 219 +++++++++++++++- backend/core-api/test/app.test.js | 19 ++ .../src/services/mobile-query-service.js | 214 +++++++++++++-- .../scripts/live-smoke-v2-unified.mjs | 154 ++++++++++- backend/unified-api/src/routes/proxy.js | 5 +- backend/unified-api/test/app.test.js | 53 ++++ docs/BACKEND/API_GUIDES/V2/README.md | 1 + docs/BACKEND/API_GUIDES/V2/staff-shifts.md | 176 +++++++++++++ docs/BACKEND/API_GUIDES/V2/unified-api.md | 18 ++ 14 files changed, 1315 insertions(+), 54 deletions(-) create mode 100644 docs/BACKEND/API_GUIDES/V2/staff-shifts.md diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js index e7f65551..5a81fd30 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -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(), diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js index be97e3c0..bc3cf9db 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -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', diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index e0017cd5..7656233c 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -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) => ({ - ...role, - startTime: role.startTime || shift.startTime, - endTime: role.endTime || shift.endTime, - }))); + 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,11 +1750,16 @@ export async function createEditedOrderCopy(actor, payload) { export async function cancelClientOrder(actor, payload) { const context = await requireClientContext(actor.uid); - return cancelOrderCommand(actor, { - tenantId: context.tenant.tenantId, - orderId: payload.orderId, - reason: payload.reason, - metadata: payload.metadata, + 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, + }); }); } @@ -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); diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js index 466e1b48..13e49964 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -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); +}); diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index 40a0ebe8..027e07af 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -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,14 +505,25 @@ 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, + file, + routeType: 'document', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); } - const result = await uploadStaffDocument({ + + const payload = parseBody(finalizedDocumentUploadSchema, req.body || {}); + const result = await finalizeStaffDocumentUpload({ actorUid: req.actor.uid, documentId: req.params.documentId, - file, routeType: 'document', + verificationId: payload.verificationId, }); return res.status(200).json({ ...result, @@ -362,14 +537,25 @@ 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, + file, + routeType: 'attire', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); } - const result = await uploadStaffDocument({ + + const payload = parseBody(finalizedDocumentUploadSchema, req.body || {}); + const result = await finalizeStaffDocumentUpload({ actorUid: req.actor.uid, documentId: req.params.documentId, - file, routeType: 'attire', + verificationId: payload.verificationId, }); return res.status(200).json({ ...result, @@ -383,13 +569,22 @@ 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, + file, + payload, + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); } - const payload = parseBody(certificateUploadMetaSchema, req.body || {}); - const result = await uploadCertificate({ + + const payload = parseBody(finalizedCertificateUploadSchema, req.body || {}); + const result = await finalizeCertificateUpload({ actorUid: req.actor.uid, - file, payload, }); return res.status(200).json({ @@ -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); diff --git a/backend/core-api/src/services/mobile-upload.js b/backend/core-api/src/services/mobile-upload.js index 392c9076..07ad0420 100644 --- a/backend/core-api/src/services/mobile-upload.js +++ b/backend/core-api/src/services/mobile-upload.js @@ -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( diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index d6613a07..f2193843 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -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(); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index cad050e9..ba0bf807 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -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 diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index 61be8e53..84374327 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -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, diff --git a/backend/unified-api/src/routes/proxy.js b/backend/unified-api/src/routes/proxy.js index 3dcc971a..8e7e5da5 100644 --- a/backend/unified-api/src/routes/proxy.js +++ b/backend/unified-api/src/routes/proxy.js @@ -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`, }, diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js index 113cfbec..02c42355 100644 --- a/backend/unified-api/test/app.test.js +++ b/backend/unified-api/test/app.test.js @@ -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'); +}); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 2025ab4e..893f64c4 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -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) diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md new file mode 100644 index 00000000..a8f85d23 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -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 ` +- `Idempotency-Key: ` + +## 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/*` diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index aea858ec..9fb50f74 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -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: