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)
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
listHubs,
|
||||
listIndustries,
|
||||
listInvoiceHistory,
|
||||
listAvailableOrders,
|
||||
listOpenShifts,
|
||||
listTaxForms,
|
||||
listTimeCardEntries,
|
||||
@@ -113,6 +114,7 @@ const defaultQueryService = {
|
||||
listHubs,
|
||||
listIndustries,
|
||||
listInvoiceHistory,
|
||||
listAvailableOrders,
|
||||
listOpenShifts,
|
||||
listTaxForms,
|
||||
listTimeCardEntries,
|
||||
@@ -355,9 +357,20 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/shifts/scheduled', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query);
|
||||
res.set('Deprecation', 'true');
|
||||
res.set('Link', '</client/shifts/scheduled>; rel="successor-version"');
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
@@ -544,6 +557,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/orders/available', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listAvailableOrders(req.actor.uid, req.query);
|
||||
return res.status(200).json({ items, requestId: req.requestId });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
|
||||
try {
|
||||
const items = await queryService.listPendingAssignments(req.actor.uid);
|
||||
|
||||
@@ -60,6 +60,44 @@ function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function resolveTimeZone(value) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', { timeZone: value || 'UTC' }).resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateInTimeZone(value, timeZone = 'UTC') {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: resolveTimeZone(timeZone),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(new Date(value));
|
||||
const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.year}-${map.month}-${map.day}`;
|
||||
}
|
||||
|
||||
function formatTimeInTimeZone(value, timeZone = 'UTC') {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: resolveTimeZone(timeZone),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(new Date(value));
|
||||
const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.hour}:${map.minute}`;
|
||||
}
|
||||
|
||||
function weekdayCodeInTimeZone(value, timeZone = 'UTC') {
|
||||
const label = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: resolveTimeZone(timeZone),
|
||||
weekday: 'short',
|
||||
}).format(new Date(value));
|
||||
return label.slice(0, 3).toUpperCase();
|
||||
}
|
||||
|
||||
function computeReliabilityScore({
|
||||
totalShifts,
|
||||
noShowCount,
|
||||
@@ -1011,6 +1049,189 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function listAvailableOrders(actorUid, { limit, search } = {}) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
o.id AS "orderId",
|
||||
COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType",
|
||||
COALESCE(sr.role_id, rc.id) AS "roleId",
|
||||
COALESCE(sr.role_code, rc.code) AS "roleCode",
|
||||
COALESCE(sr.role_name, rc.name) AS "roleName",
|
||||
b.business_name AS "clientName",
|
||||
COALESCE(cp.label, s.location_name) AS location,
|
||||
COALESCE(s.location_address, cp.address) AS "locationAddress",
|
||||
COALESCE(s.timezone, 'UTC') AS timezone,
|
||||
s.id AS "shiftId",
|
||||
s.status AS "shiftStatus",
|
||||
s.starts_at AS "startsAt",
|
||||
s.ends_at AS "endsAt",
|
||||
sr.workers_needed AS "requiredWorkerCount",
|
||||
sr.assigned_count AS "filledCount",
|
||||
COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents",
|
||||
COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS "instantBook",
|
||||
COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam",
|
||||
COALESCE(dispatch.priority, 3) AS "dispatchPriority"
|
||||
FROM orders o
|
||||
JOIN shifts s ON s.order_id = o.id
|
||||
JOIN shift_roles sr ON sr.shift_id = s.id
|
||||
LEFT JOIN roles_catalog rc
|
||||
ON rc.tenant_id = o.tenant_id
|
||||
AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code))
|
||||
JOIN businesses b ON b.id = o.business_id
|
||||
LEFT JOIN clock_points cp ON cp.id = s.clock_point_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
dtm.team_type,
|
||||
CASE dtm.team_type
|
||||
WHEN 'CORE' THEN 1
|
||||
WHEN 'CERTIFIED_LOCATION' THEN 2
|
||||
ELSE 3
|
||||
END AS priority
|
||||
FROM dispatch_team_memberships dtm
|
||||
WHERE dtm.tenant_id = $1
|
||||
AND dtm.business_id = s.business_id
|
||||
AND dtm.staff_id = $3
|
||||
AND dtm.status = 'ACTIVE'
|
||||
AND dtm.effective_at <= NOW()
|
||||
AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW())
|
||||
AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id)
|
||||
ORDER BY
|
||||
CASE dtm.team_type
|
||||
WHEN 'CORE' THEN 1
|
||||
WHEN 'CERTIFIED_LOCATION' THEN 2
|
||||
ELSE 3
|
||||
END ASC,
|
||||
CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC,
|
||||
dtm.created_at ASC
|
||||
LIMIT 1
|
||||
) dispatch ON TRUE
|
||||
WHERE o.tenant_id = $1
|
||||
AND s.starts_at > NOW()
|
||||
AND COALESCE(sr.role_code, rc.code) = $4
|
||||
AND ($2::text IS NULL OR COALESCE(sr.role_name, rc.name) ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%' OR b.business_name ILIKE '%' || $2 || '%')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM staff_blocks sb
|
||||
WHERE sb.tenant_id = o.tenant_id
|
||||
AND sb.business_id = o.business_id
|
||||
AND sb.staff_id = $3
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM applications a
|
||||
JOIN shifts sx ON sx.id = a.shift_id
|
||||
WHERE sx.order_id = o.id
|
||||
AND a.staff_id = $3
|
||||
AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM assignments a
|
||||
JOIN shifts sx ON sx.id = a.shift_id
|
||||
WHERE sx.order_id = o.id
|
||||
AND a.staff_id = $3
|
||||
AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')
|
||||
)
|
||||
ORDER BY COALESCE(dispatch.priority, 3) ASC, s.starts_at ASC
|
||||
LIMIT $5
|
||||
`,
|
||||
[
|
||||
context.tenant.tenantId,
|
||||
search || null,
|
||||
context.staff.staffId,
|
||||
context.staff.primaryRole || 'BARISTA',
|
||||
parseLimit(limit, 50, 250),
|
||||
]
|
||||
);
|
||||
|
||||
const grouped = new Map();
|
||||
for (const row of result.rows) {
|
||||
const key = `${row.orderId}:${row.roleId}`;
|
||||
const existing = grouped.get(key) || {
|
||||
orderId: row.orderId,
|
||||
orderType: row.orderType,
|
||||
roleId: row.roleId,
|
||||
roleCode: row.roleCode,
|
||||
roleName: row.roleName,
|
||||
clientName: row.clientName,
|
||||
location: row.location,
|
||||
locationAddress: row.locationAddress,
|
||||
hourlyRateCents: row.hourlyRateCents,
|
||||
hourlyRate: Number((row.hourlyRateCents / 100).toFixed(2)),
|
||||
requiredWorkerCount: row.requiredWorkerCount,
|
||||
filledCount: row.filledCount,
|
||||
instantBook: Boolean(row.instantBook),
|
||||
dispatchTeam: row.dispatchTeam,
|
||||
dispatchPriority: row.dispatchPriority,
|
||||
timezone: resolveTimeZone(row.timezone),
|
||||
shifts: [],
|
||||
};
|
||||
existing.requiredWorkerCount = Math.max(existing.requiredWorkerCount, row.requiredWorkerCount);
|
||||
existing.filledCount = Math.max(existing.filledCount, row.filledCount);
|
||||
existing.instantBook = existing.instantBook && Boolean(row.instantBook);
|
||||
existing.dispatchPriority = Math.min(existing.dispatchPriority, row.dispatchPriority);
|
||||
existing.dispatchTeam = existing.dispatchPriority === 1
|
||||
? 'CORE'
|
||||
: existing.dispatchPriority === 2
|
||||
? 'CERTIFIED_LOCATION'
|
||||
: 'MARKETPLACE';
|
||||
existing.shifts.push({
|
||||
shiftId: row.shiftId,
|
||||
shiftStatus: row.shiftStatus,
|
||||
startsAt: row.startsAt,
|
||||
endsAt: row.endsAt,
|
||||
});
|
||||
grouped.set(key, existing);
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.filter((item) => item.shifts.length > 0)
|
||||
.filter((item) => item.shifts.every((shift) => shift.shiftStatus === 'OPEN'))
|
||||
.filter((item) => item.filledCount < item.requiredWorkerCount)
|
||||
.sort((left, right) => {
|
||||
if (left.dispatchPriority !== right.dispatchPriority) {
|
||||
return left.dispatchPriority - right.dispatchPriority;
|
||||
}
|
||||
return new Date(left.shifts[0].startsAt).getTime() - new Date(right.shifts[0].startsAt).getTime();
|
||||
})
|
||||
.slice(0, parseLimit(limit, 20, 100))
|
||||
.map((item) => {
|
||||
const firstShift = item.shifts[0];
|
||||
const lastShift = item.shifts[item.shifts.length - 1];
|
||||
const daysOfWeek = [...new Set(item.shifts.map((shift) => weekdayCodeInTimeZone(shift.startsAt, item.timezone)))];
|
||||
return {
|
||||
orderId: item.orderId,
|
||||
orderType: item.orderType,
|
||||
roleId: item.roleId,
|
||||
roleCode: item.roleCode,
|
||||
roleName: item.roleName,
|
||||
clientName: item.clientName,
|
||||
location: item.location,
|
||||
locationAddress: item.locationAddress,
|
||||
hourlyRateCents: item.hourlyRateCents,
|
||||
hourlyRate: item.hourlyRate,
|
||||
requiredWorkerCount: item.requiredWorkerCount,
|
||||
filledCount: item.filledCount,
|
||||
instantBook: item.instantBook,
|
||||
dispatchTeam: item.dispatchTeam,
|
||||
dispatchPriority: item.dispatchPriority,
|
||||
schedule: {
|
||||
totalShifts: item.shifts.length,
|
||||
startDate: formatDateInTimeZone(firstShift.startsAt, item.timezone),
|
||||
endDate: formatDateInTimeZone(lastShift.startsAt, item.timezone),
|
||||
daysOfWeek,
|
||||
startTime: formatTimeInTimeZone(firstShift.startsAt, item.timezone),
|
||||
endTime: formatTimeInTimeZone(firstShift.endsAt, item.timezone),
|
||||
timezone: item.timezone,
|
||||
firstShiftStartsAt: firstShift.startsAt,
|
||||
lastShiftEndsAt: lastShift.endsAt,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listOpenShifts(actorUid, { limit, search } = {}) {
|
||||
const context = await requireStaffContext(actorUid);
|
||||
const result = await query(
|
||||
|
||||
@@ -48,6 +48,7 @@ function createMobileQueryService() {
|
||||
listHubs: async () => ([{ hubId: 'hub-1' }]),
|
||||
listIndustries: async () => (['CATERING']),
|
||||
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
|
||||
listAvailableOrders: async () => ([{ orderId: 'order-available-1', roleId: 'role-catalog-1' }]),
|
||||
listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
|
||||
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
|
||||
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]),
|
||||
@@ -123,6 +124,39 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async ()
|
||||
assert.equal(res.body.shiftId, 'shift-1');
|
||||
});
|
||||
|
||||
test('GET /query/staff/orders/available returns injected order-level opportunities', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/staff/orders/available')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items[0].orderId, 'order-available-1');
|
||||
assert.equal(res.body.items[0].roleId, 'role-catalog-1');
|
||||
});
|
||||
|
||||
test('GET /query/client/shifts/scheduled returns injected shift timeline items', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/client/shifts/scheduled?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.items[0].itemId, 'item-1');
|
||||
});
|
||||
|
||||
test('GET /query/client/orders/view remains as deprecated compatibility alias', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
.get('/query/client/orders/view?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z')
|
||||
.set('Authorization', 'Bearer test-token');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.deprecation, 'true');
|
||||
assert.equal(res.headers.link, '</client/shifts/scheduled>; rel="successor-version"');
|
||||
assert.equal(res.body.items[0].itemId, 'item-1');
|
||||
});
|
||||
|
||||
test('GET /query/client/reports/summary returns injected report summary', async () => {
|
||||
const app = createApp({ mobileQueryService: createMobileQueryService() });
|
||||
const res = await request(app)
|
||||
|
||||
@@ -449,6 +449,16 @@ async function main() {
|
||||
}
|
||||
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
|
||||
|
||||
const scheduledShifts = await apiCall(`/client/shifts/scheduled?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(scheduledShifts.items));
|
||||
assert.equal(scheduledShifts.items.length, viewedOrders.items.length);
|
||||
if (viewedOrders.items[0] && scheduledShifts.items[0]) {
|
||||
assert.equal(scheduledShifts.items[0].itemId, viewedOrders.items[0].itemId);
|
||||
}
|
||||
logStep('client.shifts.scheduled.ok', { count: scheduledShifts.items.length });
|
||||
|
||||
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
@@ -814,6 +824,33 @@ async function main() {
|
||||
assert.ok(Array.isArray(assignedShifts.items));
|
||||
logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length });
|
||||
|
||||
const availableOrders = await apiCall('/staff/orders/available?limit=20', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
const availableOrder = availableOrders.items.find((item) => item.orderId === createdRecurringOrder.orderId)
|
||||
|| availableOrders.items[0];
|
||||
assert.ok(availableOrder);
|
||||
assert.ok(availableOrder.roleId);
|
||||
logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: availableOrder.orderId });
|
||||
|
||||
const bookedOrder = await apiCall(`/staff/orders/${availableOrder.orderId}/book`, {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-order-book'),
|
||||
body: {
|
||||
roleId: availableOrder.roleId,
|
||||
},
|
||||
});
|
||||
assert.equal(bookedOrder.orderId, availableOrder.orderId);
|
||||
assert.ok(bookedOrder.assignedShiftCount >= 1);
|
||||
assert.equal(bookedOrder.status, 'PENDING');
|
||||
assert.ok(Array.isArray(bookedOrder.assignedShifts));
|
||||
logStep('staff.orders.book.ok', {
|
||||
orderId: bookedOrder.orderId,
|
||||
assignedShiftCount: bookedOrder.assignedShiftCount,
|
||||
status: bookedOrder.status,
|
||||
});
|
||||
|
||||
const openShifts = await apiCall('/staff/shifts/open', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
@@ -827,6 +864,9 @@ async function main() {
|
||||
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(
|
||||
bookedOrder.assignedShifts.some((shift) => pendingShifts.items.some((item) => item.shiftId === shift.shiftId))
|
||||
);
|
||||
const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id)
|
||||
|| pendingShifts.items[0];
|
||||
assert.ok(pendingShift);
|
||||
|
||||
Reference in New Issue
Block a user