278 lines
7.8 KiB
JavaScript
278 lines
7.8 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 {
|
|
addFavoriteStaff,
|
|
clockIn,
|
|
clockOut,
|
|
createOrder,
|
|
createStaffReview,
|
|
updateOrder,
|
|
cancelOrder,
|
|
changeShiftStatus,
|
|
assignStaffToShift,
|
|
removeFavoriteStaff,
|
|
acceptShift,
|
|
} from '../services/command-service.js';
|
|
import { attendanceCommandSchema } from '../contracts/commands/attendance.js';
|
|
import { favoriteStaffSchema } from '../contracts/commands/favorite-staff.js';
|
|
import { orderCancelSchema } from '../contracts/commands/order-cancel.js';
|
|
import { orderCreateSchema } from '../contracts/commands/order-create.js';
|
|
import { orderUpdateSchema } from '../contracts/commands/order-update.js';
|
|
import { shiftAssignStaffSchema } from '../contracts/commands/shift-assign-staff.js';
|
|
import { shiftAcceptSchema } from '../contracts/commands/shift-accept.js';
|
|
import { shiftStatusChangeSchema } from '../contracts/commands/shift-status-change.js';
|
|
import { staffReviewSchema } from '../contracts/commands/staff-review.js';
|
|
|
|
const defaultHandlers = {
|
|
addFavoriteStaff,
|
|
assignStaffToShift,
|
|
cancelOrder,
|
|
changeShiftStatus,
|
|
clockIn,
|
|
clockOut,
|
|
createOrder,
|
|
createStaffReview,
|
|
removeFavoriteStaff,
|
|
acceptShift,
|
|
updateOrder,
|
|
};
|
|
|
|
function parseBody(schema, body) {
|
|
const parsed = schema.safeParse(body || {});
|
|
if (!parsed.success) {
|
|
throw new AppError('VALIDATION_ERROR', 'Invalid command 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);
|
|
}
|
|
|
|
export function createCommandsRouter(handlers = defaultHandlers) {
|
|
const router = Router();
|
|
|
|
router.post(
|
|
'/orders/create',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('orders.create', 'order'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(orderCreateSchema, req.body);
|
|
return await runIdempotentCommand(req, res, () => handlers.createOrder(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/orders/:orderId/update',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('orders.update', 'order'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(orderUpdateSchema, {
|
|
...req.body,
|
|
orderId: req.params.orderId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.updateOrder(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/orders/:orderId/cancel',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('orders.cancel', 'order'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(orderCancelSchema, {
|
|
...req.body,
|
|
orderId: req.params.orderId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.cancelOrder(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/shifts/:shiftId/change-status',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('shifts.change-status', 'shift'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(shiftStatusChangeSchema, {
|
|
...req.body,
|
|
shiftId: req.params.shiftId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.changeShiftStatus(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/shifts/:shiftId/assign-staff',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('shifts.assign-staff', 'shift'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(shiftAssignStaffSchema, {
|
|
...req.body,
|
|
shiftId: req.params.shiftId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.assignStaffToShift(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/shifts/:shiftId/accept',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('shifts.accept', 'shift'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(shiftAcceptSchema, {
|
|
...req.body,
|
|
shiftId: req.params.shiftId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.acceptShift(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/attendance/clock-in',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('attendance.clock-in', 'attendance'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(attendanceCommandSchema, req.body);
|
|
return await runIdempotentCommand(req, res, () => handlers.clockIn(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/attendance/clock-out',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('attendance.clock-out', 'attendance'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(attendanceCommandSchema, req.body);
|
|
return await runIdempotentCommand(req, res, () => handlers.clockOut(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/businesses/:businessId/favorite-staff',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('business.favorite-staff', 'staff'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(favoriteStaffSchema, {
|
|
...req.body,
|
|
businessId: req.params.businessId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.addFavoriteStaff(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.delete(
|
|
'/businesses/:businessId/favorite-staff/:staffId',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('business.unfavorite-staff', 'staff'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(favoriteStaffSchema, {
|
|
...req.body,
|
|
businessId: req.params.businessId,
|
|
staffId: req.params.staffId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.removeFavoriteStaff(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/assignments/:assignmentId/reviews',
|
|
requireAuth,
|
|
requireIdempotencyKey,
|
|
requirePolicy('assignments.review-staff', 'assignment'),
|
|
async (req, res, next) => {
|
|
try {
|
|
const payload = parseBody(staffReviewSchema, {
|
|
...req.body,
|
|
assignmentId: req.params.assignmentId,
|
|
});
|
|
return await runIdempotentCommand(req, res, () => handlers.createStaffReview(req.actor, payload));
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
return router;
|
|
}
|