feat(api): complete unified v2 mobile surface
This commit is contained in:
412
backend/command-api/src/routes/mobile.js
Normal file
412
backend/command-api/src/routes/mobile.js
Normal file
@@ -0,0 +1,412 @@
|
||||
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,
|
||||
cancelClientOrder,
|
||||
createEmergencyContact,
|
||||
createClientOneTimeOrder,
|
||||
createClientPermanentOrder,
|
||||
createClientRecurringOrder,
|
||||
createEditedOrderCopy,
|
||||
createHub,
|
||||
declinePendingShift,
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitTaxForm,
|
||||
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,
|
||||
emergencyContactCreateSchema,
|
||||
emergencyContactUpdateSchema,
|
||||
hubAssignManagerSchema,
|
||||
hubAssignNfcSchema,
|
||||
hubCreateSchema,
|
||||
hubDeleteSchema,
|
||||
hubUpdateSchema,
|
||||
invoiceApproveSchema,
|
||||
invoiceDisputeSchema,
|
||||
personalInfoUpdateSchema,
|
||||
preferredLocationsUpdateSchema,
|
||||
privacyUpdateSchema,
|
||||
profileExperienceSchema,
|
||||
shiftApplySchema,
|
||||
shiftDecisionSchema,
|
||||
staffClockInSchema,
|
||||
staffClockOutSchema,
|
||||
staffProfileSetupSchema,
|
||||
taxFormDraftSchema,
|
||||
taxFormSubmitSchema,
|
||||
} from '../contracts/commands/mobile.js';
|
||||
|
||||
const defaultHandlers = {
|
||||
acceptPendingShift,
|
||||
addStaffBankAccount,
|
||||
approveInvoice,
|
||||
applyForShift,
|
||||
assignHubManager,
|
||||
assignHubNfc,
|
||||
cancelLateWorker,
|
||||
cancelClientOrder,
|
||||
createEmergencyContact,
|
||||
createClientOneTimeOrder,
|
||||
createClientPermanentOrder,
|
||||
createClientRecurringOrder,
|
||||
createEditedOrderCopy,
|
||||
createHub,
|
||||
declinePendingShift,
|
||||
disputeInvoice,
|
||||
quickSetStaffAvailability,
|
||||
rateWorkerFromCoverage,
|
||||
requestShiftSwap,
|
||||
saveTaxFormDraft,
|
||||
setupStaffProfile,
|
||||
staffClockIn,
|
||||
staffClockOut,
|
||||
submitTaxForm,
|
||||
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/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('/staff/profile/setup', {
|
||||
schema: staffProfileSetupSchema,
|
||||
policyAction: 'staff.profile.write',
|
||||
resource: 'staff',
|
||||
handler: handlers.setupStaffProfile,
|
||||
}));
|
||||
|
||||
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.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.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;
|
||||
}
|
||||
Reference in New Issue
Block a user