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

@@ -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);

View File

@@ -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(

View File

@@ -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)