feat(backend): implement v2 domain slice and live smoke
This commit is contained in:
@@ -3,10 +3,45 @@ 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 { commandBaseSchema } from '../contracts/commands/command-base.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';
|
||||
|
||||
function parseBody(body) {
|
||||
const parsed = commandBaseSchema.safeParse(body || {});
|
||||
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,
|
||||
@@ -15,50 +50,37 @@ function parseBody(body) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function createCommandResponse(route, requestId, idempotencyKey) {
|
||||
return {
|
||||
accepted: true,
|
||||
async function runIdempotentCommand(req, res, work) {
|
||||
const route = `${req.baseUrl}${req.route.path}`;
|
||||
const compositeKey = buildIdempotencyKey({
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
commandId: `${route}:${Date.now()}`,
|
||||
idempotencyKey,
|
||||
requestId,
|
||||
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 buildCommandHandler(policyAction, policyResource) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
parseBody(req.body);
|
||||
|
||||
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 = createCommandResponse(route, req.requestId, req.idempotencyKey);
|
||||
const persisted = await writeIdempotentResult({
|
||||
compositeKey,
|
||||
userId: req.actor.uid,
|
||||
route,
|
||||
idempotencyKey: req.idempotencyKey,
|
||||
payload,
|
||||
statusCode: 200,
|
||||
});
|
||||
return res.status(persisted.statusCode).json(persisted.payload);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCommandsRouter() {
|
||||
export function createCommandsRouter(handlers = defaultHandlers) {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
@@ -66,7 +88,14 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.create', 'order'),
|
||||
buildCommandHandler('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(
|
||||
@@ -74,7 +103,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.update', 'order'),
|
||||
buildCommandHandler('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(
|
||||
@@ -82,7 +121,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('orders.cancel', 'order'),
|
||||
buildCommandHandler('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(
|
||||
@@ -90,7 +139,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.change-status', 'shift'),
|
||||
buildCommandHandler('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(
|
||||
@@ -98,7 +157,17 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.assign-staff', 'shift'),
|
||||
buildCommandHandler('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(
|
||||
@@ -106,7 +175,102 @@ export function createCommandsRouter() {
|
||||
requireAuth,
|
||||
requireIdempotencyKey,
|
||||
requirePolicy('shifts.accept', 'shift'),
|
||||
buildCommandHandler('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;
|
||||
|
||||
Reference in New Issue
Block a user