import { Router } from 'express'; import { AppError } from '../lib/errors.js'; import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { requireIdempotencyKey } from '../middleware/idempotency.js'; import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js'; import { addStaffBankAccount, approveInvoice, applyForShift, assignHubManager, assignHubNfc, cancelLateWorker, cancelShiftSwapRequest, cancelClientOrder, createDispatchTeamMembership, createEmergencyContact, createClientOneTimeOrder, createClientPermanentOrder, createClientRecurringOrder, createEditedOrderCopy, createHub, createShiftManager, declinePendingShift, disputeInvoice, quickSetStaffAvailability, rateWorkerFromCoverage, registerClientPushToken, registerStaffPushToken, removeDispatchTeamMembership, resolveShiftSwapRequest, requestShiftSwap, saveTaxFormDraft, setupStaffProfile, staffClockIn, staffClockOut, submitCompletedShiftForApproval, submitLocationStreamBatch, submitTaxForm, unregisterClientPushToken, unregisterStaffPushToken, updateEmergencyContact, updateHub, updatePersonalInfo, updatePreferredLocations, updatePrivacyVisibility, updateProfileExperience, updateStaffAvailabilityDay, deleteHub, acceptPendingShift, } from '../services/mobile-command-service.js'; import { availabilityDayUpdateSchema, availabilityQuickSetSchema, bankAccountCreateSchema, cancelLateWorkerSchema, clientOneTimeOrderSchema, clientOrderCancelSchema, clientOrderEditSchema, clientPermanentOrderSchema, clientRecurringOrderSchema, coverageReviewSchema, dispatchTeamMembershipCreateSchema, dispatchTeamMembershipDeleteSchema, emergencyContactCreateSchema, emergencyContactUpdateSchema, hubAssignManagerSchema, hubAssignNfcSchema, hubCreateSchema, hubDeleteSchema, hubUpdateSchema, invoiceApproveSchema, invoiceDisputeSchema, personalInfoUpdateSchema, preferredLocationsUpdateSchema, privacyUpdateSchema, profileExperienceSchema, pushTokenDeleteSchema, pushTokenRegisterSchema, shiftManagerCreateSchema, shiftApplySchema, shiftDecisionSchema, shiftSwapCancelSchema, shiftSwapResolveSchema, shiftSubmitApprovalSchema, staffClockInSchema, staffClockOutSchema, staffLocationBatchSchema, staffProfileSetupSchema, taxFormDraftSchema, taxFormSubmitSchema, } from '../contracts/commands/mobile.js'; const defaultHandlers = { acceptPendingShift, addStaffBankAccount, approveInvoice, applyForShift, assignHubManager, assignHubNfc, cancelLateWorker, cancelShiftSwapRequest, cancelClientOrder, createDispatchTeamMembership, createEmergencyContact, createClientOneTimeOrder, createClientPermanentOrder, createClientRecurringOrder, createEditedOrderCopy, createHub, createShiftManager, declinePendingShift, disputeInvoice, quickSetStaffAvailability, rateWorkerFromCoverage, registerClientPushToken, registerStaffPushToken, removeDispatchTeamMembership, resolveShiftSwapRequest, requestShiftSwap, saveTaxFormDraft, setupStaffProfile, staffClockIn, staffClockOut, submitCompletedShiftForApproval, submitLocationStreamBatch, submitTaxForm, unregisterClientPushToken, unregisterStaffPushToken, updateEmergencyContact, updateHub, updatePersonalInfo, updatePreferredLocations, updatePrivacyVisibility, updateProfileExperience, updateStaffAvailabilityDay, deleteHub, }; function parseBody(schema, body) { const parsed = schema.safeParse(body || {}); if (!parsed.success) { throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, { issues: parsed.error.issues, }); } return parsed.data; } async function runIdempotentCommand(req, res, work) { const route = `${req.baseUrl}${req.route.path}`; const compositeKey = buildIdempotencyKey({ userId: req.actor.uid, route, idempotencyKey: req.idempotencyKey, }); const existing = await readIdempotentResult(compositeKey); if (existing) { return res.status(existing.statusCode).json(existing.payload); } const payload = await work(); const responsePayload = { ...payload, idempotencyKey: req.idempotencyKey, requestId: req.requestId, }; const persisted = await writeIdempotentResult({ compositeKey, userId: req.actor.uid, route, idempotencyKey: req.idempotencyKey, payload: responsePayload, statusCode: 200, }); return res.status(persisted.statusCode).json(persisted.payload); } function mobileCommand(route, { schema, policyAction, resource, handler, paramShape }) { return [ route, requireAuth, requireIdempotencyKey, requirePolicy(policyAction, resource), async (req, res, next) => { try { const body = typeof paramShape === 'function' ? paramShape(req) : req.body; const payload = parseBody(schema, body); return await runIdempotentCommand(req, res, () => handler(req.actor, payload)); } catch (error) { return next(error); } }, ]; } export function createMobileCommandsRouter(handlers = defaultHandlers) { const router = Router(); router.post(...mobileCommand('/client/orders/one-time', { schema: clientOneTimeOrderSchema, policyAction: 'orders.create', resource: 'order', handler: handlers.createClientOneTimeOrder, })); router.post(...mobileCommand('/client/orders/recurring', { schema: clientRecurringOrderSchema, policyAction: 'orders.create', resource: 'order', handler: handlers.createClientRecurringOrder, })); router.post(...mobileCommand('/client/orders/permanent', { schema: clientPermanentOrderSchema, policyAction: 'orders.create', resource: 'order', handler: handlers.createClientPermanentOrder, })); router.post(...mobileCommand('/client/orders/:orderId/edit', { schema: clientOrderEditSchema, policyAction: 'orders.update', resource: 'order', handler: handlers.createEditedOrderCopy, paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }), })); router.post(...mobileCommand('/client/orders/:orderId/cancel', { schema: clientOrderCancelSchema, policyAction: 'orders.cancel', resource: 'order', handler: handlers.cancelClientOrder, paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }), })); router.post(...mobileCommand('/client/hubs', { schema: hubCreateSchema, policyAction: 'client.hubs.create', resource: 'hub', handler: handlers.createHub, })); router.put(...mobileCommand('/client/hubs/:hubId', { schema: hubUpdateSchema, policyAction: 'client.hubs.update', resource: 'hub', handler: handlers.updateHub, paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), })); router.delete(...mobileCommand('/client/hubs/:hubId', { schema: hubDeleteSchema, policyAction: 'client.hubs.delete', resource: 'hub', handler: handlers.deleteHub, paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), })); router.post(...mobileCommand('/client/hubs/:hubId/assign-nfc', { schema: hubAssignNfcSchema, policyAction: 'client.hubs.update', resource: 'hub', handler: handlers.assignHubNfc, paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), })); router.post(...mobileCommand('/client/hubs/:hubId/managers', { schema: hubAssignManagerSchema, policyAction: 'client.hubs.update', resource: 'hub', handler: handlers.assignHubManager, paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), })); router.post(...mobileCommand('/client/shift-managers', { schema: shiftManagerCreateSchema, policyAction: 'client.hubs.update', resource: 'hub_manager', handler: handlers.createShiftManager, })); router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', { schema: invoiceApproveSchema, policyAction: 'client.billing.write', resource: 'invoice', handler: handlers.approveInvoice, paramShape: (req) => ({ invoiceId: req.params.invoiceId }), })); router.post(...mobileCommand('/client/billing/invoices/:invoiceId/dispute', { schema: invoiceDisputeSchema, policyAction: 'client.billing.write', resource: 'invoice', handler: handlers.disputeInvoice, paramShape: (req) => ({ ...req.body, invoiceId: req.params.invoiceId }), })); router.post(...mobileCommand('/client/coverage/reviews', { schema: coverageReviewSchema, policyAction: 'client.coverage.write', resource: 'staff_review', handler: handlers.rateWorkerFromCoverage, })); router.post(...mobileCommand('/client/coverage/late-workers/:assignmentId/cancel', { schema: cancelLateWorkerSchema, policyAction: 'client.coverage.write', resource: 'assignment', handler: handlers.cancelLateWorker, paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }), })); router.post(...mobileCommand('/client/coverage/swap-requests/:swapRequestId/resolve', { schema: shiftSwapResolveSchema, policyAction: 'client.coverage.write', resource: 'shift_swap_request', handler: handlers.resolveShiftSwapRequest, paramShape: (req) => ({ ...req.body, swapRequestId: req.params.swapRequestId }), })); router.post(...mobileCommand('/client/coverage/swap-requests/:swapRequestId/cancel', { schema: shiftSwapCancelSchema, policyAction: 'client.coverage.write', resource: 'shift_swap_request', handler: handlers.cancelShiftSwapRequest, paramShape: (req) => ({ ...req.body, swapRequestId: req.params.swapRequestId }), })); router.post(...mobileCommand('/client/coverage/dispatch-teams/memberships', { schema: dispatchTeamMembershipCreateSchema, policyAction: 'client.coverage.write', resource: 'dispatch_team', handler: handlers.createDispatchTeamMembership, })); router.delete(...mobileCommand('/client/coverage/dispatch-teams/memberships/:membershipId', { schema: dispatchTeamMembershipDeleteSchema, policyAction: 'client.coverage.write', resource: 'dispatch_team', handler: handlers.removeDispatchTeamMembership, paramShape: (req) => ({ ...req.body, membershipId: req.params.membershipId, reason: req.body?.reason || req.query.reason, }), })); router.post(...mobileCommand('/staff/profile/setup', { schema: staffProfileSetupSchema, policyAction: 'staff.profile.write', resource: 'staff', handler: handlers.setupStaffProfile, })); router.post(...mobileCommand('/client/devices/push-tokens', { schema: pushTokenRegisterSchema, policyAction: 'notifications.device.write', resource: 'device_push_token', handler: handlers.registerClientPushToken, })); router.delete(...mobileCommand('/client/devices/push-tokens', { schema: pushTokenDeleteSchema, policyAction: 'notifications.device.write', resource: 'device_push_token', handler: handlers.unregisterClientPushToken, paramShape: (req) => ({ ...req.body, tokenId: req.body?.tokenId || req.query.tokenId, pushToken: req.body?.pushToken || req.query.pushToken, reason: req.body?.reason || req.query.reason, }), })); router.post(...mobileCommand('/staff/clock-in', { schema: staffClockInSchema, policyAction: 'attendance.clock-in', resource: 'attendance', handler: handlers.staffClockIn, })); router.post(...mobileCommand('/staff/clock-out', { schema: staffClockOutSchema, policyAction: 'attendance.clock-out', resource: 'attendance', handler: handlers.staffClockOut, })); router.post(...mobileCommand('/staff/location-streams', { schema: staffLocationBatchSchema, policyAction: 'attendance.location-stream.write', resource: 'attendance', handler: handlers.submitLocationStreamBatch, })); router.post(...mobileCommand('/staff/devices/push-tokens', { schema: pushTokenRegisterSchema, policyAction: 'notifications.device.write', resource: 'device_push_token', handler: handlers.registerStaffPushToken, })); router.delete(...mobileCommand('/staff/devices/push-tokens', { schema: pushTokenDeleteSchema, policyAction: 'notifications.device.write', resource: 'device_push_token', handler: handlers.unregisterStaffPushToken, paramShape: (req) => ({ ...req.body, tokenId: req.body?.tokenId || req.query.tokenId, pushToken: req.body?.pushToken || req.query.pushToken, reason: req.body?.reason || req.query.reason, }), })); router.put(...mobileCommand('/staff/availability', { schema: availabilityDayUpdateSchema, policyAction: 'staff.availability.write', resource: 'staff', handler: handlers.updateStaffAvailabilityDay, })); router.post(...mobileCommand('/staff/availability/quick-set', { schema: availabilityQuickSetSchema, policyAction: 'staff.availability.write', resource: 'staff', handler: handlers.quickSetStaffAvailability, })); router.post(...mobileCommand('/staff/shifts/:shiftId/apply', { schema: shiftApplySchema, policyAction: 'staff.shifts.apply', resource: 'shift', handler: handlers.applyForShift, paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), })); router.post(...mobileCommand('/staff/shifts/:shiftId/accept', { schema: shiftDecisionSchema, policyAction: 'staff.shifts.accept', resource: 'shift', handler: handlers.acceptPendingShift, paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), })); router.post(...mobileCommand('/staff/shifts/:shiftId/decline', { schema: shiftDecisionSchema, policyAction: 'staff.shifts.decline', resource: 'shift', handler: handlers.declinePendingShift, paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), })); router.post(...mobileCommand('/staff/shifts/:shiftId/request-swap', { schema: shiftDecisionSchema, policyAction: 'staff.shifts.swap', resource: 'shift', handler: handlers.requestShiftSwap, 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', resource: 'staff', handler: handlers.updatePersonalInfo, })); router.put(...mobileCommand('/staff/profile/experience', { schema: profileExperienceSchema, policyAction: 'staff.profile.write', resource: 'staff', handler: handlers.updateProfileExperience, })); router.put(...mobileCommand('/staff/profile/locations', { schema: preferredLocationsUpdateSchema, policyAction: 'staff.profile.write', resource: 'staff', handler: handlers.updatePreferredLocations, })); router.post(...mobileCommand('/staff/profile/emergency-contacts', { schema: emergencyContactCreateSchema, policyAction: 'staff.profile.write', resource: 'staff', handler: handlers.createEmergencyContact, })); router.put(...mobileCommand('/staff/profile/emergency-contacts/:contactId', { schema: emergencyContactUpdateSchema, policyAction: 'staff.profile.write', resource: 'staff', handler: handlers.updateEmergencyContact, paramShape: (req) => ({ ...req.body, contactId: req.params.contactId }), })); router.put(...mobileCommand('/staff/profile/tax-forms/:formType', { schema: taxFormDraftSchema, policyAction: 'staff.profile.write', resource: 'staff_document', handler: handlers.saveTaxFormDraft, paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }), })); router.post(...mobileCommand('/staff/profile/tax-forms/:formType/submit', { schema: taxFormSubmitSchema, policyAction: 'staff.profile.write', resource: 'staff_document', handler: handlers.submitTaxForm, paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }), })); router.post(...mobileCommand('/staff/profile/bank-accounts', { schema: bankAccountCreateSchema, policyAction: 'staff.profile.write', resource: 'account', handler: handlers.addStaffBankAccount, })); router.put(...mobileCommand('/staff/profile/privacy', { schema: privacyUpdateSchema, policyAction: 'staff.profile.write', resource: 'staff', handler: handlers.updatePrivacyVisibility, })); return router; }