diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 1dab4a2b..55dc47fb 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -17,6 +17,6 @@ class AppConfig { /// The base URL for the V2 Unified API gateway. static const String v2ApiBaseUrl = String.fromEnvironment( 'V2_API_BASE_URL', - defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app', + defaultValue: 'https://krow-api-v2-e3g6witsvq-uc.a.run.app', ); } diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js index f4f0d567..181850ae 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -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(), diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js index 99bb8c8b..309ff014 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -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', diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index 377ab9af..def1d189 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -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) => { diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js index 276c9a11..4328dfff 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -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) diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 947c2bc1..31bbd090 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -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', '; 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); diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index e8942dff..9ed9ae15 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -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( diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index f810029d..428163de 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -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, '; 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) diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index d3080cb4..f67e2503 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -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); diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 1b812994..4d8eb6d6 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -7,7 +7,7 @@ This is the frontend-facing source of truth for the v2 backend. Frontend should call one public gateway: ```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. @@ -95,14 +95,12 @@ Source-of-truth timestamp fields include: - `startsAt` - `endsAt` -- `startTime` -- `endTime` - `clockInAt` - `clockOutAt` - `createdAt` - `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 diff --git a/docs/BACKEND/API_GUIDES/V2/authentication.md b/docs/BACKEND/API_GUIDES/V2/authentication.md index c45cf5de..b644210f 100644 --- a/docs/BACKEND/API_GUIDES/V2/authentication.md +++ b/docs/BACKEND/API_GUIDES/V2/authentication.md @@ -5,7 +5,7 @@ This document is the source of truth for V2 authentication. Base URL: ```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 diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md index b594eb5b..977b8fa8 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md @@ -22,7 +22,7 @@ That includes: 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 - `krow-sql-v2` - `krow-core-api-v2` diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md index cb3f91d5..a7a2f940 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -6,7 +6,7 @@ Use this as the primary implementation brief. Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` Supporting docs: @@ -23,6 +23,7 @@ Supporting docs: - Send `Idempotency-Key` on every write route. - 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 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. ## 2) What is implemented now @@ -90,8 +91,10 @@ Do not assume staff auth is a fully backend-managed OTP flow. Rules: - `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 /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 ## 5) Client app screen mapping @@ -165,7 +168,8 @@ Swap management flow: ### Orders -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated alias - `GET /client/orders/:orderId/reorder-preview` - `POST /client/orders/one-time` - `POST /client/orders/recurring` @@ -230,12 +234,17 @@ Important: ### Find shifts +- `GET /staff/orders/available` +- `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` 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 diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md index a72f198a..a4847d66 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -4,7 +4,7 @@ This is the shortest path for frontend to implement the v2 mobile clients agains 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: @@ -30,7 +30,10 @@ Important consequences: - `GET /staff/shifts/open` returns open shift-role opportunities. - `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. ## 3) Auth implementation @@ -122,7 +125,8 @@ Dispatch-priority rule: ### Orders -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated alias - `GET /client/orders/:orderId/reorder-preview` - `POST /client/orders/one-time` - `POST /client/orders/recurring` @@ -175,13 +179,17 @@ Rapid-order flow: ### Find shifts +- `GET /staff/orders/available` +- `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- send the `roleId` from the open-shifts response -- this is the concrete `shift_roles.id` +- send the `roleId` from the order-available response when booking an order +- 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 diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md index 802cdf75..d881449d 100644 --- a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -4,10 +4,11 @@ This document is the frontend handoff for the `staff/shifts/*` routes on the uni Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` ## Read routes +- `GET /staff/orders/available` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -17,6 +18,7 @@ Base URL: ## Write routes +- `POST /staff/orders/:orderId/book` - `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/decline` @@ -30,6 +32,68 @@ All write routes require: ## 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 `GET /staff/shifts/open` diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 06153c0f..dcbf40c7 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -2,7 +2,7 @@ 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. @@ -54,7 +54,8 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /client/vendors/:vendorId/roles` - `GET /client/hubs/:hubId/managers` - `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/reports/summary` - `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` - `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: ```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/history` - `GET /staff/payments/chart` +- `GET /staff/orders/available` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `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 - `POST /staff/profile/setup` @@ -249,6 +265,7 @@ Example `GET /staff/profile/stats` response: - `POST /staff/location-streams` - `PUT /staff/availability` - `POST /staff/availability/quick-set` +- `POST /staff/orders/:orderId/book` - `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/decline`