feat(api): add staff order booking contract and shift timeline alias

This commit is contained in:
zouantchaw
2026-03-19 16:07:25 +01:00
parent 4b2ef9d843
commit 1d5c0e3b80
16 changed files with 766 additions and 19 deletions

View File

@@ -202,6 +202,11 @@ export const shiftApplySchema = z.object({
instantBook: z.boolean().optional(),
});
export const orderBookSchema = z.object({
orderId: z.string().uuid(),
roleId: z.string().uuid(),
});
export const shiftDecisionSchema = z.object({
shiftId: z.string().uuid(),
reason: z.string().max(1000).optional(),

View File

@@ -7,6 +7,7 @@ import {
addStaffBankAccount,
approveInvoice,
applyForShift,
bookOrder,
assignHubManager,
assignHubNfc,
cancelLateWorker,
@@ -76,6 +77,7 @@ import {
profileExperienceSchema,
pushTokenDeleteSchema,
pushTokenRegisterSchema,
orderBookSchema,
shiftManagerCreateSchema,
shiftApplySchema,
shiftDecisionSchema,
@@ -95,6 +97,7 @@ const defaultHandlers = {
addStaffBankAccount,
approveInvoice,
applyForShift,
bookOrder,
assignHubManager,
assignHubNfc,
cancelLateWorker,
@@ -438,6 +441,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }),
}));
router.post(...mobileCommand('/staff/orders/:orderId/book', {
schema: orderBookSchema,
policyAction: 'staff.orders.book',
resource: 'order',
handler: handlers.bookOrder,
paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }),
}));
router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
schema: shiftDecisionSchema,
policyAction: 'staff.shifts.accept',

View File

@@ -2883,6 +2883,299 @@ export async function applyForShift(actor, payload) {
});
}
export async function bookOrder(actor, payload) {
const context = await requireStaffContext(actor.uid);
return withTransaction(async (client) => {
await ensureActorUser(client, actor);
const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid);
if (!staff.workforce_id) {
throw new AppError('UNPROCESSABLE_ENTITY', 'Staff must have an active workforce profile before booking an order', 422, {
orderId: payload.orderId,
staffId: staff.id,
});
}
const roleLookup = await client.query(
`
SELECT id, code, name
FROM roles_catalog
WHERE tenant_id = $1
AND id = $2
AND status = 'ACTIVE'
LIMIT 1
`,
[context.tenant.tenantId, payload.roleId]
);
if (roleLookup.rowCount === 0) {
throw new AppError('VALIDATION_ERROR', 'roleId must reference an active role in the tenant catalog', 400, {
roleId: payload.roleId,
});
}
const selectedRole = roleLookup.rows[0];
const orderLookup = await client.query(
`
SELECT id, business_id, metadata
FROM orders
WHERE tenant_id = $1
AND id = $2
LIMIT 1
FOR UPDATE
`,
[context.tenant.tenantId, payload.orderId]
);
if (orderLookup.rowCount === 0) {
throw new AppError('NOT_FOUND', 'Order not found', 404, {
orderId: payload.orderId,
});
}
const existingOrderParticipation = await client.query(
`
SELECT
s.id AS shift_id,
sr.id AS shift_role_id,
a.id AS assignment_id,
app.id AS application_id
FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id
LEFT JOIN assignments a
ON a.shift_role_id = sr.id
AND a.staff_id = $3
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
LEFT JOIN applications app
ON app.shift_role_id = sr.id
AND app.staff_id = $3
AND app.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED')
WHERE s.tenant_id = $1
AND s.order_id = $2
AND s.starts_at > NOW()
AND (a.id IS NOT NULL OR app.id IS NOT NULL)
LIMIT 1
`,
[context.tenant.tenantId, payload.orderId, staff.id]
);
if (existingOrderParticipation.rowCount > 0) {
throw new AppError('CONFLICT', 'Staff already has participation on this order', 409, {
orderId: payload.orderId,
shiftId: existingOrderParticipation.rows[0].shift_id,
shiftRoleId: existingOrderParticipation.rows[0].shift_role_id,
});
}
const candidateRoles = await client.query(
`
SELECT
s.id AS shift_id,
s.order_id,
s.business_id,
s.vendor_id,
s.clock_point_id,
s.status AS shift_status,
s.starts_at,
s.ends_at,
COALESCE(s.timezone, 'UTC') AS timezone,
to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'YYYY-MM-DD') AS local_date,
to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_start_time,
to_char(s.ends_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_end_time,
sr.id AS shift_role_id,
COALESCE(sr.role_id, rc.id) AS catalog_role_id,
COALESCE(sr.role_code, rc.code) AS role_code,
COALESCE(sr.role_name, rc.name) AS role_name,
sr.workers_needed,
sr.assigned_count,
COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS instant_book
FROM shifts s
JOIN shift_roles sr ON sr.shift_id = s.id
LEFT JOIN roles_catalog rc
ON rc.tenant_id = s.tenant_id
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
WHERE s.tenant_id = $1
AND s.order_id = $2
AND s.starts_at > NOW()
AND COALESCE(sr.role_id, rc.id) = $3
ORDER BY s.starts_at ASC, sr.created_at ASC
FOR UPDATE OF s, sr
`,
[context.tenant.tenantId, payload.orderId, payload.roleId]
);
if (candidateRoles.rowCount === 0) {
throw new AppError('UNPROCESSABLE_ENTITY', 'Order has no future shifts available for this role', 422, {
orderId: payload.orderId,
roleId: payload.roleId,
});
}
const blockedOrUnavailable = candidateRoles.rows.find((row) => row.shift_status !== 'OPEN' || row.assigned_count >= row.workers_needed);
if (blockedOrUnavailable) {
throw new AppError('UNPROCESSABLE_ENTITY', 'Order is no longer fully bookable', 422, {
orderId: payload.orderId,
roleId: payload.roleId,
shiftId: blockedOrUnavailable.shift_id,
shiftRoleId: blockedOrUnavailable.shift_role_id,
});
}
await ensureStaffNotBlockedByBusiness(client, {
tenantId: context.tenant.tenantId,
businessId: candidateRoles.rows[0].business_id,
staffId: staff.id,
});
const bookingId = crypto.randomUUID();
const assignedShifts = [];
for (const row of candidateRoles.rows) {
const dispatchMembership = await loadDispatchMembership(client, {
tenantId: context.tenant.tenantId,
businessId: row.business_id,
hubId: row.clock_point_id,
staffId: staff.id,
});
const instantBook = Boolean(row.instant_book);
const applicationResult = await client.query(
`
INSERT INTO applications (
tenant_id,
shift_id,
shift_role_id,
staff_id,
status,
origin,
metadata
)
VALUES ($1, $2, $3, $4, $5, 'STAFF', $6::jsonb)
ON CONFLICT (shift_role_id, staff_id) DO NOTHING
RETURNING id, status
`,
[
context.tenant.tenantId,
row.shift_id,
row.shift_role_id,
staff.id,
instantBook ? 'CONFIRMED' : 'PENDING',
JSON.stringify({
bookingId,
bookedBy: actor.uid,
source: 'staff-order-booking',
orderId: payload.orderId,
catalogRoleId: payload.roleId,
roleCode: selectedRole.code,
dispatchTeamType: dispatchMembership.teamType,
dispatchPriority: dispatchMembership.priority,
dispatchTeamMembershipId: dispatchMembership.membershipId,
dispatchTeamScopeHubId: dispatchMembership.scopedHubId,
}),
]
);
if (applicationResult.rowCount === 0) {
throw new AppError('CONFLICT', 'Order booking conflicted with an existing application', 409, {
orderId: payload.orderId,
shiftId: row.shift_id,
shiftRoleId: row.shift_role_id,
});
}
const assignmentResult = await client.query(
`
INSERT INTO assignments (
tenant_id,
business_id,
vendor_id,
shift_id,
shift_role_id,
workforce_id,
staff_id,
application_id,
status,
assigned_at,
accepted_at,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), CASE WHEN $10::boolean THEN NOW() ELSE NULL END, $11::jsonb)
ON CONFLICT (shift_role_id, workforce_id) DO NOTHING
RETURNING id, status
`,
[
context.tenant.tenantId,
row.business_id,
row.vendor_id,
row.shift_id,
row.shift_role_id,
staff.workforce_id,
staff.id,
applicationResult.rows[0].id,
instantBook ? 'ACCEPTED' : 'ASSIGNED',
instantBook,
JSON.stringify({
bookingId,
bookedBy: actor.uid,
source: 'staff-order-booking',
orderId: payload.orderId,
catalogRoleId: payload.roleId,
roleCode: selectedRole.code,
pendingApproval: !instantBook,
dispatchTeamType: dispatchMembership.teamType,
dispatchPriority: dispatchMembership.priority,
dispatchTeamMembershipId: dispatchMembership.membershipId,
dispatchTeamScopeHubId: dispatchMembership.scopedHubId,
}),
]
);
if (assignmentResult.rowCount === 0) {
throw new AppError('CONFLICT', 'Order booking conflicted with an existing assignment', 409, {
orderId: payload.orderId,
shiftId: row.shift_id,
shiftRoleId: row.shift_role_id,
});
}
await refreshShiftRoleCounts(client, row.shift_role_id);
await refreshShiftCounts(client, row.shift_id);
assignedShifts.push({
shiftId: row.shift_id,
date: row.local_date,
startsAt: row.starts_at,
endsAt: row.ends_at,
startTime: row.local_start_time,
endTime: row.local_end_time,
timezone: row.timezone,
assignmentId: assignmentResult.rows[0].id,
assignmentStatus: assignmentResult.rows[0].status,
});
}
await insertDomainEvent(client, {
tenantId: context.tenant.tenantId,
aggregateType: 'order',
aggregateId: payload.orderId,
eventType: candidateRoles.rows.every((row) => row.instant_book) ? 'STAFF_ORDER_BOOKED_CONFIRMED' : 'STAFF_ORDER_BOOKED_PENDING',
actorUserId: actor.uid,
payload: {
bookingId,
roleId: payload.roleId,
roleCode: selectedRole.code,
assignedShiftCount: assignedShifts.length,
},
});
return {
bookingId,
orderId: payload.orderId,
roleId: payload.roleId,
roleCode: selectedRole.code,
roleName: selectedRole.name,
assignedShiftCount: assignedShifts.length,
status: candidateRoles.rows.every((row) => row.instant_book) ? 'CONFIRMED' : 'PENDING',
assignedShifts,
};
});
}
export async function acceptPendingShift(actor, payload) {
const context = await requireStaffContext(actor.uid);
return withTransaction(async (client) => {

View File

@@ -65,6 +65,14 @@ function createMobileHandlers() {
invoiceId: payload.invoiceId,
status: 'APPROVED',
}),
bookOrder: async (_actor, payload) => ({
bookingId: 'booking-1',
orderId: payload.orderId,
roleId: payload.roleId,
assignedShiftCount: 3,
status: 'PENDING',
assignedShifts: [],
}),
registerClientPushToken: async (_actor, payload) => ({
tokenId: 'push-token-client-1',
platform: payload.platform,
@@ -410,6 +418,23 @@ test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id
assert.equal(res.body.submitted, true);
});
test('POST /commands/staff/orders/:orderId/book injects order id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)
.post('/commands/staff/orders/88888888-8888-4888-8888-888888888888/book')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'staff-order-book-1')
.send({
roleId: '99999999-9999-4999-8999-999999999999',
});
assert.equal(res.status, 200);
assert.equal(res.body.orderId, '88888888-8888-4888-8888-888888888888');
assert.equal(res.body.roleId, '99999999-9999-4999-8999-999999999999');
assert.equal(res.body.assignedShiftCount, 3);
assert.equal(res.body.status, 'PENDING');
});
test('POST /commands/client/coverage/swap-requests/:swapRequestId/resolve injects swap request id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app)