541 lines
16 KiB
JavaScript
541 lines
16 KiB
JavaScript
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;
|
|
}
|