Merge pull request #666 from Oloodi/codex/feat-m5-order-booking-contract

feat(api): add staff order booking endpoints and shift timeline route
This commit is contained in:
Achintha Isuru
2026-03-19 11:27:23 -04:00
committed by GitHub
16 changed files with 766 additions and 19 deletions

View File

@@ -17,6 +17,6 @@ class AppConfig {
/// The base URL for the V2 Unified API gateway. /// The base URL for the V2 Unified API gateway.
static const String v2ApiBaseUrl = String.fromEnvironment( static const String v2ApiBaseUrl = String.fromEnvironment(
'V2_API_BASE_URL', 'V2_API_BASE_URL',
defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app', defaultValue: 'https://krow-api-v2-e3g6witsvq-uc.a.run.app',
); );
} }

View File

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

View File

@@ -7,6 +7,7 @@ import {
addStaffBankAccount, addStaffBankAccount,
approveInvoice, approveInvoice,
applyForShift, applyForShift,
bookOrder,
assignHubManager, assignHubManager,
assignHubNfc, assignHubNfc,
cancelLateWorker, cancelLateWorker,
@@ -76,6 +77,7 @@ import {
profileExperienceSchema, profileExperienceSchema,
pushTokenDeleteSchema, pushTokenDeleteSchema,
pushTokenRegisterSchema, pushTokenRegisterSchema,
orderBookSchema,
shiftManagerCreateSchema, shiftManagerCreateSchema,
shiftApplySchema, shiftApplySchema,
shiftDecisionSchema, shiftDecisionSchema,
@@ -95,6 +97,7 @@ const defaultHandlers = {
addStaffBankAccount, addStaffBankAccount,
approveInvoice, approveInvoice,
applyForShift, applyForShift,
bookOrder,
assignHubManager, assignHubManager,
assignHubNfc, assignHubNfc,
cancelLateWorker, cancelLateWorker,
@@ -438,6 +441,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) {
paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), 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', { router.post(...mobileCommand('/staff/shifts/:shiftId/accept', {
schema: shiftDecisionSchema, schema: shiftDecisionSchema,
policyAction: 'staff.shifts.accept', 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) { export async function acceptPendingShift(actor, payload) {
const context = await requireStaffContext(actor.uid); const context = await requireStaffContext(actor.uid);
return withTransaction(async (client) => { return withTransaction(async (client) => {

View File

@@ -65,6 +65,14 @@ function createMobileHandlers() {
invoiceId: payload.invoiceId, invoiceId: payload.invoiceId,
status: 'APPROVED', status: 'APPROVED',
}), }),
bookOrder: async (_actor, payload) => ({
bookingId: 'booking-1',
orderId: payload.orderId,
roleId: payload.roleId,
assignedShiftCount: 3,
status: 'PENDING',
assignedShifts: [],
}),
registerClientPushToken: async (_actor, payload) => ({ registerClientPushToken: async (_actor, payload) => ({
tokenId: 'push-token-client-1', tokenId: 'push-token-client-1',
platform: payload.platform, 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); 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 () => { test('POST /commands/client/coverage/swap-requests/:swapRequestId/resolve injects swap request id from params', async () => {
const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); const app = createApp({ mobileCommandHandlers: createMobileHandlers() });
const res = await request(app) const res = await request(app)

View File

@@ -44,6 +44,7 @@ import {
listHubs, listHubs,
listIndustries, listIndustries,
listInvoiceHistory, listInvoiceHistory,
listAvailableOrders,
listOpenShifts, listOpenShifts,
listTaxForms, listTaxForms,
listTimeCardEntries, listTimeCardEntries,
@@ -113,6 +114,7 @@ const defaultQueryService = {
listHubs, listHubs,
listIndustries, listIndustries,
listInvoiceHistory, listInvoiceHistory,
listAvailableOrders,
listOpenShifts, listOpenShifts,
listTaxForms, listTaxForms,
listTimeCardEntries, 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) => { router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => {
try { try {
const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); 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 }); return res.status(200).json({ items, requestId: req.requestId });
} catch (error) { } catch (error) {
return next(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) => { router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => {
try { try {
const items = await queryService.listPendingAssignments(req.actor.uid); 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); 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({ function computeReliabilityScore({
totalShifts, totalShifts,
noShowCount, noShowCount,
@@ -1011,6 +1049,189 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) {
return result.rows; 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 } = {}) { export async function listOpenShifts(actorUid, { limit, search } = {}) {
const context = await requireStaffContext(actorUid); const context = await requireStaffContext(actorUid);
const result = await query( const result = await query(

View File

@@ -48,6 +48,7 @@ function createMobileQueryService() {
listHubs: async () => ([{ hubId: 'hub-1' }]), listHubs: async () => ([{ hubId: 'hub-1' }]),
listIndustries: async () => (['CATERING']), listIndustries: async () => (['CATERING']),
listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]), listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]),
listAvailableOrders: async () => ([{ orderId: 'order-available-1', roleId: 'role-catalog-1' }]),
listOpenShifts: async () => ([{ shiftId: 'open-1' }]), listOpenShifts: async () => ([{ shiftId: 'open-1' }]),
getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }), getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }),
listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]), 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'); 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 () => { test('GET /query/client/reports/summary returns injected report summary', async () => {
const app = createApp({ mobileQueryService: createMobileQueryService() }); const app = createApp({ mobileQueryService: createMobileQueryService() });
const res = await request(app) const res = await request(app)

View File

@@ -449,6 +449,16 @@ async function main() {
} }
logStep('client.orders.view.ok', { count: viewedOrders.items.length }); 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`, { const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
token: ownerSession.sessionToken, token: ownerSession.sessionToken,
}); });
@@ -814,6 +824,33 @@ async function main() {
assert.ok(Array.isArray(assignedShifts.items)); assert.ok(Array.isArray(assignedShifts.items));
logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length }); 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', { const openShifts = await apiCall('/staff/shifts/open', {
token: staffAuth.idToken, token: staffAuth.idToken,
}); });
@@ -827,6 +864,9 @@ async function main() {
const pendingShifts = await apiCall('/staff/shifts/pending', { const pendingShifts = await apiCall('/staff/shifts/pending', {
token: staffAuth.idToken, 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) const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id)
|| pendingShifts.items[0]; || pendingShifts.items[0];
assert.ok(pendingShift); assert.ok(pendingShift);

View File

@@ -7,7 +7,7 @@ This is the frontend-facing source of truth for the v2 backend.
Frontend should call one public gateway: Frontend should call one public gateway:
```env ```env
API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app
``` ```
Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly. Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly.
@@ -95,14 +95,12 @@ Source-of-truth timestamp fields include:
- `startsAt` - `startsAt`
- `endsAt` - `endsAt`
- `startTime`
- `endTime`
- `clockInAt` - `clockInAt`
- `clockOutAt` - `clockOutAt`
- `createdAt` - `createdAt`
- `updatedAt` - `updatedAt`
Helper fields like `date` are UTC-derived helpers and should not replace the raw timestamp fields. Helper fields like `date`, `startTime`, and `endTime` are display helpers and should not replace the raw timestamp fields.
## 4) Attendance policy and monitoring ## 4) Attendance policy and monitoring

View File

@@ -5,7 +5,7 @@ This document is the source of truth for V2 authentication.
Base URL: Base URL:
```env ```env
API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app
``` ```
## 1) What is implemented ## 1) What is implemented

View File

@@ -22,7 +22,7 @@ That includes:
The live smoke executed successfully against: The live smoke executed successfully against:
- `https://krow-api-v2-933560802882.us-central1.run.app` - `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
- Firebase demo users - Firebase demo users
- `krow-sql-v2` - `krow-sql-v2`
- `krow-core-api-v2` - `krow-core-api-v2`

View File

@@ -6,7 +6,7 @@ Use this as the primary implementation brief.
Base URL: Base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app` - `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
Supporting docs: Supporting docs:
@@ -23,6 +23,7 @@ Supporting docs:
- Send `Idempotency-Key` on every write route. - Send `Idempotency-Key` on every write route.
- Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects.
- For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. - For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`.
- For staff order booking, `roleId` must come from the response of `GET /staff/orders/available`.
- Treat API timestamp fields as UTC and convert them to local time in the app. - Treat API timestamp fields as UTC and convert them to local time in the app.
## 2) What is implemented now ## 2) What is implemented now
@@ -90,8 +91,10 @@ Do not assume staff auth is a fully backend-managed OTP flow.
Rules: Rules:
- `GET /staff/shifts/open` returns opportunities, not assignments - `GET /staff/shifts/open` returns opportunities, not assignments
- `GET /staff/orders/available` returns grouped order opportunities for booking
- `GET /staff/shifts/assigned` returns active assigned shifts - `GET /staff/shifts/assigned` returns active assigned shifts
- `GET /client/orders/view` is the timeline/read model for client - `GET /client/shifts/scheduled` is the canonical timeline/read model for client
- `GET /client/orders/view` is now a deprecated compatibility alias
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only
## 5) Client app screen mapping ## 5) Client app screen mapping
@@ -165,7 +168,8 @@ Swap management flow:
### Orders ### Orders
- `GET /client/orders/view` - `GET /client/shifts/scheduled`
- `GET /client/orders/view` deprecated alias
- `GET /client/orders/:orderId/reorder-preview` - `GET /client/orders/:orderId/reorder-preview`
- `POST /client/orders/one-time` - `POST /client/orders/one-time`
- `POST /client/orders/recurring` - `POST /client/orders/recurring`
@@ -230,12 +234,17 @@ Important:
### Find shifts ### Find shifts
- `GET /staff/orders/available`
- `POST /staff/orders/:orderId/book`
- `GET /staff/shifts/open` - `GET /staff/shifts/open`
- `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/apply`
Rule: Rule:
- use `roleId` from the open-shifts response - use `roleId` from the order-available response when booking an order
- that `roleId` is the role catalog id for the grouped order booking flow
- use `roleId` from the open-shifts response only for shift-level apply
- that `roleId` is the concrete `shift_roles.id`
### My shifts ### My shifts

View File

@@ -4,7 +4,7 @@ This is the shortest path for frontend to implement the v2 mobile clients agains
Base URL: Base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app` - `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
Use this doc together with: Use this doc together with:
@@ -30,7 +30,10 @@ Important consequences:
- `GET /staff/shifts/open` returns open shift-role opportunities. - `GET /staff/shifts/open` returns open shift-role opportunities.
- `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response. - `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response.
- `GET /client/orders/view` is the timeline/read model for the client app. - `GET /staff/orders/available` returns grouped order opportunities for atomic booking.
- `POST /staff/orders/:orderId/book` must send the `roleId` from that response.
- `GET /client/shifts/scheduled` is the canonical timeline/read model for the client app.
- `GET /client/orders/view` is a deprecated compatibility alias.
- `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts. - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts.
## 3) Auth implementation ## 3) Auth implementation
@@ -122,7 +125,8 @@ Dispatch-priority rule:
### Orders ### Orders
- `GET /client/orders/view` - `GET /client/shifts/scheduled`
- `GET /client/orders/view` deprecated alias
- `GET /client/orders/:orderId/reorder-preview` - `GET /client/orders/:orderId/reorder-preview`
- `POST /client/orders/one-time` - `POST /client/orders/one-time`
- `POST /client/orders/recurring` - `POST /client/orders/recurring`
@@ -175,13 +179,17 @@ Rapid-order flow:
### Find shifts ### Find shifts
- `GET /staff/orders/available`
- `POST /staff/orders/:orderId/book`
- `GET /staff/shifts/open` - `GET /staff/shifts/open`
- `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/apply`
Rule: Rule:
- send the `roleId` from the open-shifts response - send the `roleId` from the order-available response when booking an order
- this is the concrete `shift_roles.id` - this `roleId` is the role catalog id for grouped order booking
- send the `roleId` from the open-shifts response only when applying to one shift
- that route still uses the concrete `shift_roles.id`
### My shifts ### My shifts

View File

@@ -4,10 +4,11 @@ This document is the frontend handoff for the `staff/shifts/*` routes on the uni
Base URL: Base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app` - `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
## Read routes ## Read routes
- `GET /staff/orders/available`
- `GET /staff/shifts/assigned` - `GET /staff/shifts/assigned`
- `GET /staff/shifts/open` - `GET /staff/shifts/open`
- `GET /staff/shifts/pending` - `GET /staff/shifts/pending`
@@ -17,6 +18,7 @@ Base URL:
## Write routes ## Write routes
- `POST /staff/orders/:orderId/book`
- `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/apply`
- `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline` - `POST /staff/shifts/:shiftId/decline`
@@ -30,6 +32,68 @@ All write routes require:
## Shift lifecycle ## Shift lifecycle
### Find work by order
`GET /staff/orders/available`
- use this for grouped recurring or permanent work cards
- each item represents one order plus one role
- this feed is already filtered to the current worker context
- `schedule` gives the preview for the whole booking window
Example response:
```json
{
"orderId": "uuid",
"orderType": "RECURRING",
"roleId": "uuid",
"roleCode": "BARISTA",
"roleName": "Barista",
"clientName": "Google Mountain View Cafes",
"location": "Google MV Cafe Clock Point",
"locationAddress": "1600 Amphitheatre Pkwy, Mountain View, CA",
"hourlyRateCents": 2300,
"hourlyRate": 23,
"requiredWorkerCount": 1,
"filledCount": 0,
"instantBook": false,
"dispatchTeam": "CORE",
"dispatchPriority": 1,
"schedule": {
"totalShifts": 3,
"startDate": "2026-03-24",
"endDate": "2026-03-28",
"daysOfWeek": ["WED", "FRI"],
"startTime": "09:00",
"endTime": "15:00",
"timezone": "America/Los_Angeles",
"firstShiftStartsAt": "2026-03-25T16:00:00.000Z",
"lastShiftEndsAt": "2026-03-27T22:00:00.000Z"
}
}
```
`POST /staff/orders/:orderId/book`
- use this when the worker books the full order instead of one shift
- booking is atomic across the future shifts in that order for the selected role
- backend returns `PENDING` when the booking is reserved but not instant-booked
- backend returns `CONFIRMED` when every future shift in that booking path is instant-booked
Example request:
```json
{
"roleId": "uuid"
}
```
Important:
- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/available`
- it is not the same thing as the per-shift `shift_roles.id`
### Find shifts ### Find shifts
`GET /staff/shifts/open` `GET /staff/shifts/open`

View File

@@ -2,7 +2,7 @@
Frontend should use this service as the single base URL: Frontend should use this service as the single base URL:
- `https://krow-api-v2-933560802882.us-central1.run.app` - `https://krow-api-v2-e3g6witsvq-uc.a.run.app`
The gateway keeps backend services separate internally, but frontend should treat it as one API. The gateway keeps backend services separate internally, but frontend should treat it as one API.
@@ -54,7 +54,8 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `GET /client/vendors/:vendorId/roles` - `GET /client/vendors/:vendorId/roles`
- `GET /client/hubs/:hubId/managers` - `GET /client/hubs/:hubId/managers`
- `GET /client/team-members` - `GET /client/team-members`
- `GET /client/orders/view` - `GET /client/shifts/scheduled`
- `GET /client/orders/view` deprecated compatibility alias
- `GET /client/orders/:orderId/reorder-preview` - `GET /client/orders/:orderId/reorder-preview`
- `GET /client/reports/summary` - `GET /client/reports/summary`
- `GET /client/reports/daily-ops` - `GET /client/reports/daily-ops`
@@ -88,6 +89,12 @@ Full auth behavior, including staff phone flow and refresh rules, is documented
- `POST /client/coverage/dispatch-teams/memberships` - `POST /client/coverage/dispatch-teams/memberships`
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId` - `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`
Timeline route naming:
- `GET /client/shifts/scheduled` is the canonical client timeline route
- it returns shift-level scheduled items, not order headers
- `GET /client/orders/view` still returns the same payload for compatibility, but now emits a deprecation header
Coverage-review request payload may also send: Coverage-review request payload may also send:
```json ```json
@@ -176,6 +183,7 @@ The manager is created as an invited business membership. If `hubId` is present,
- `GET /staff/payments/summary` - `GET /staff/payments/summary`
- `GET /staff/payments/history` - `GET /staff/payments/history`
- `GET /staff/payments/chart` - `GET /staff/payments/chart`
- `GET /staff/orders/available`
- `GET /staff/shifts/assigned` - `GET /staff/shifts/assigned`
- `GET /staff/shifts/open` - `GET /staff/shifts/open`
- `GET /staff/shifts/pending` - `GET /staff/shifts/pending`
@@ -239,6 +247,14 @@ Example `GET /staff/profile/stats` response:
} }
``` ```
Order booking route notes:
- `GET /staff/orders/available` is the canonical order-level marketplace feed for recurring and grouped work
- `GET /staff/shifts/open` remains available for shift-level opportunities and swap coverage
- `POST /staff/orders/:orderId/book` books the future shifts of an order atomically for one role
- the `roleId` returned by `GET /staff/orders/available` is the role catalog id for the order booking flow
- the `roleId` returned by `GET /staff/shifts/open` is still the concrete `shift_roles.id` for shift-level apply
### Staff writes ### Staff writes
- `POST /staff/profile/setup` - `POST /staff/profile/setup`
@@ -249,6 +265,7 @@ Example `GET /staff/profile/stats` response:
- `POST /staff/location-streams` - `POST /staff/location-streams`
- `PUT /staff/availability` - `PUT /staff/availability`
- `POST /staff/availability/quick-set` - `POST /staff/availability/quick-set`
- `POST /staff/orders/:orderId/book`
- `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/apply`
- `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline` - `POST /staff/shifts/:shiftId/decline`