feat(api): add staff order booking contract and shift timeline alias
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user